chore: public-readiness cleanup
- Remove default Grafana password (fail loudly if unset) - Clean up stale delivery-proof TODO (already implemented at RPC layer) - Document TUI send as local-only, point to REPL for E2E delivery - Gitignore AI workflow files (CLAUDE.md, master-prompt.md, ai_team.py) - Remove 5 orphaned v1 crates (bot, ffi, gen, gui, mobile) - Commit ROADMAP.html updates
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "quicproquo-bot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Bot SDK for quicproquo — build automated agents on E2E encrypted messaging."
|
||||
license = "MIT"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
quicproquo-core = { path = "../quicproquo-core" }
|
||||
quicproquo-proto = { path = "../quicproquo-proto" }
|
||||
quicproquo-client = { path = "../quicproquo-client" }
|
||||
|
||||
openmls_rust_crypto = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
@@ -1,353 +0,0 @@
|
||||
//! # quicproquo-bot — Bot SDK for E2E encrypted messaging
|
||||
//!
|
||||
//! Build automated agents that run on the quicproquo network with full MLS
|
||||
//! end-to-end encryption. The bot SDK wraps the client library into a simple
|
||||
//! polling-based API: connect, authenticate, send, receive.
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use quicproquo_bot::{Bot, BotConfig};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let config = BotConfig::new("127.0.0.1:7000", "bot-user", "bot-password")
|
||||
//! .ca_cert("server-cert.der")
|
||||
//! .state_path("bot-state.bin");
|
||||
//!
|
||||
//! let bot = Bot::connect(config).await?;
|
||||
//!
|
||||
//! // Send a DM
|
||||
//! bot.send_dm("alice", "Hello from bot!").await?;
|
||||
//!
|
||||
//! // Poll for messages
|
||||
//! loop {
|
||||
//! for msg in bot.receive(5000).await? {
|
||||
//! println!("{}: {}", msg.sender, msg.text);
|
||||
//! if msg.text.starts_with("!echo ") {
|
||||
//! bot.send_dm(&msg.sender, &msg.text[6..]).await?;
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Pipe mode (stdin/stdout JSON lines)
|
||||
//!
|
||||
//! The bot SDK also supports non-interactive pipe mode for shell integration:
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Send via pipe
|
||||
//! echo '{"to":"alice","text":"hello"}' | qpq pipe --state bot.bin
|
||||
//!
|
||||
//! # Receive via pipe (JSON lines to stdout)
|
||||
//! qpq pipe --recv --state bot.bin
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::task::LocalSet;
|
||||
|
||||
use quicproquo_client::{connect_node, init_auth, opaque_login, resolve_user, ClientAuth};
|
||||
use quicproquo_core::IdentityKeypair;
|
||||
|
||||
/// Configuration for connecting a bot to a quicproquo server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BotConfig {
|
||||
/// Server address (host:port).
|
||||
pub server: String,
|
||||
/// Path to the server's CA certificate (DER format).
|
||||
pub ca_cert: PathBuf,
|
||||
/// TLS server name (defaults to "localhost").
|
||||
pub server_name: String,
|
||||
/// Bot's username for OPAQUE authentication.
|
||||
pub username: String,
|
||||
/// Bot's password for OPAQUE authentication.
|
||||
pub password: String,
|
||||
/// Path to the bot's encrypted state file.
|
||||
pub state_path: PathBuf,
|
||||
/// Password for the encrypted state file (None = unencrypted).
|
||||
pub state_password: Option<String>,
|
||||
/// Device ID reported to the server.
|
||||
pub device_id: Option<String>,
|
||||
}
|
||||
|
||||
impl BotConfig {
|
||||
/// Create a new bot configuration with required fields.
|
||||
pub fn new(server: &str, username: &str, password: &str) -> Self {
|
||||
Self {
|
||||
server: server.to_string(),
|
||||
ca_cert: PathBuf::from("server-cert.der"),
|
||||
server_name: "localhost".to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
state_path: PathBuf::from("bot-state.bin"),
|
||||
state_password: None,
|
||||
device_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the CA certificate path.
|
||||
pub fn ca_cert(mut self, path: &str) -> Self {
|
||||
self.ca_cert = PathBuf::from(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the TLS server name for certificate validation.
|
||||
pub fn server_name(mut self, name: &str) -> Self {
|
||||
self.server_name = name.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the state file path.
|
||||
pub fn state_path(mut self, path: &str) -> Self {
|
||||
self.state_path = PathBuf::from(path);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the state file encryption password.
|
||||
pub fn state_password(mut self, pwd: &str) -> Self {
|
||||
self.state_password = Some(pwd.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the device ID.
|
||||
pub fn device_id(mut self, id: &str) -> Self {
|
||||
self.device_id = Some(id.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A received message from the quicproquo network.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct Message {
|
||||
/// The sender's username (or "unknown" if resolution failed).
|
||||
pub sender: String,
|
||||
/// The decrypted plaintext message content.
|
||||
pub text: String,
|
||||
/// Server-assigned sequence number.
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
/// A bot connected to a quicproquo server.
|
||||
///
|
||||
/// The bot maintains its identity and MLS group state. Each call to
|
||||
/// `send_dm` or `receive` opens a fresh QUIC connection (stateless
|
||||
/// reconnect pattern — same as the CLI client).
|
||||
pub struct Bot {
|
||||
config: BotConfig,
|
||||
identity: Arc<IdentityKeypair>,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
/// Connect to a quicproquo server and authenticate.
|
||||
///
|
||||
/// Loads or creates an identity from the state file, connects via QUIC/TLS,
|
||||
/// and performs OPAQUE password authentication.
|
||||
pub async fn connect(config: BotConfig) -> anyhow::Result<Self> {
|
||||
let state = quicproquo_client::client::state::load_or_init_state(
|
||||
&config.state_path,
|
||||
config.state_password.as_deref(),
|
||||
)
|
||||
.context("load or init bot state")?;
|
||||
|
||||
let identity = Arc::new(IdentityKeypair::from_seed(state.identity_seed));
|
||||
|
||||
// Authenticate on the first connection.
|
||||
let local = LocalSet::new();
|
||||
let cfg = config.clone();
|
||||
let id = Arc::clone(&identity);
|
||||
|
||||
local
|
||||
.run_until(async {
|
||||
let client =
|
||||
connect_node(&cfg.server, &cfg.ca_cert, &cfg.server_name).await?;
|
||||
|
||||
let pk = id.public_key_bytes();
|
||||
let token = opaque_login(
|
||||
&client,
|
||||
&cfg.username,
|
||||
&cfg.password,
|
||||
&pk,
|
||||
)
|
||||
.await
|
||||
.context("OPAQUE login")?;
|
||||
|
||||
init_auth(ClientAuth::from_raw(token, cfg.device_id.clone()));
|
||||
|
||||
tracing::info!(username = %cfg.username, server = %cfg.server, "bot authenticated");
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(Self { config, identity })
|
||||
}
|
||||
|
||||
/// Send a plaintext message to a peer by username.
|
||||
///
|
||||
/// Resolves the username to an identity key, then encrypts via MLS
|
||||
/// and delivers through the server.
|
||||
pub async fn send_dm(&self, peer_username: &str, text: &str) -> anyhow::Result<()> {
|
||||
// Resolve username → identity key hex so we send to the specific peer.
|
||||
let peer_key = self
|
||||
.resolve_user(peer_username)
|
||||
.await
|
||||
.context("resolve peer username")?;
|
||||
let peer_key_hex = hex::encode(&peer_key);
|
||||
|
||||
quicproquo_client::cmd_send(
|
||||
&self.config.state_path,
|
||||
&self.config.server,
|
||||
&self.config.ca_cert,
|
||||
&self.config.server_name,
|
||||
Some(&peer_key_hex),
|
||||
false,
|
||||
text,
|
||||
self.config.state_password.as_deref(),
|
||||
)
|
||||
.await
|
||||
.context("send message")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive pending messages, waiting up to `timeout_ms` milliseconds.
|
||||
///
|
||||
/// Returns decrypted application messages. MLS control messages (commits,
|
||||
/// welcomes) are processed internally but not returned.
|
||||
pub async fn receive(&self, timeout_ms: u64) -> anyhow::Result<Vec<Message>> {
|
||||
let plaintexts = quicproquo_client::receive_pending_plaintexts(
|
||||
&self.config.state_path,
|
||||
&self.config.server,
|
||||
&self.config.ca_cert,
|
||||
&self.config.server_name,
|
||||
timeout_ms,
|
||||
self.config.state_password.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let messages: Vec<Message> = plaintexts
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, plaintext)| Message {
|
||||
sender: "peer".to_string(), // TODO: resolve from MLS group roster
|
||||
text: String::from_utf8_lossy(&plaintext).to_string(),
|
||||
seq: i as u64,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Receive raw plaintext bytes (for binary protocols or non-UTF-8 content).
|
||||
pub async fn receive_raw(&self, timeout_ms: u64) -> anyhow::Result<Vec<Vec<u8>>> {
|
||||
quicproquo_client::receive_pending_plaintexts(
|
||||
&self.config.state_path,
|
||||
&self.config.server,
|
||||
&self.config.ca_cert,
|
||||
&self.config.server_name,
|
||||
timeout_ms,
|
||||
self.config.state_password.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resolve a username to a 32-byte identity key.
|
||||
pub async fn resolve_user(&self, username: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let local = LocalSet::new();
|
||||
let cfg = self.config.clone();
|
||||
let username = username.to_string();
|
||||
|
||||
local
|
||||
.run_until(async {
|
||||
let client = connect_node(&cfg.server, &cfg.ca_cert, &cfg.server_name).await?;
|
||||
let key = resolve_user(&client, &username)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("user not found: {username}"))?;
|
||||
Ok(key)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get the bot's own username.
|
||||
pub fn username(&self) -> &str {
|
||||
&self.config.username
|
||||
}
|
||||
|
||||
/// Get the bot's identity public key (32 bytes, Ed25519).
|
||||
pub fn identity_key(&self) -> [u8; 32] {
|
||||
self.identity.public_key_bytes()
|
||||
}
|
||||
|
||||
/// Get the bot's identity key as a hex string.
|
||||
pub fn identity_key_hex(&self) -> String {
|
||||
hex::encode(self.identity.public_key_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read JSON commands from stdin and process them.
|
||||
///
|
||||
/// Each line should be a JSON object with:
|
||||
/// - `{"action": "send", "to": "username", "text": "message"}`
|
||||
/// - `{"action": "recv", "timeout_ms": 5000}`
|
||||
/// - `{"action": "resolve", "username": "alice"}`
|
||||
///
|
||||
/// Results are written to stdout as JSON lines.
|
||||
pub async fn run_pipe_mode(bot: &Bot) -> anyhow::Result<()> {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
let stdin = BufReader::new(tokio::io::stdin());
|
||||
let mut lines = stdin.lines();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let line = line.trim().to_string();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cmd: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let err = serde_json::json!({"error": format!("invalid JSON: {e}")});
|
||||
println!("{err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let action = cmd["action"].as_str().unwrap_or("");
|
||||
let result = match action {
|
||||
"send" => {
|
||||
let to = cmd["to"].as_str().unwrap_or("");
|
||||
let text = cmd["text"].as_str().unwrap_or("");
|
||||
match bot.send_dm(to, text).await {
|
||||
Ok(()) => serde_json::json!({"status": "ok", "action": "send"}),
|
||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
||||
}
|
||||
}
|
||||
"recv" => {
|
||||
let timeout = cmd["timeout_ms"].as_u64().unwrap_or(5000);
|
||||
match bot.receive(timeout).await {
|
||||
Ok(msgs) => serde_json::json!({"status": "ok", "messages": msgs}),
|
||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
||||
}
|
||||
}
|
||||
"resolve" => {
|
||||
let username = cmd["username"].as_str().unwrap_or("");
|
||||
match bot.resolve_user(username).await {
|
||||
Ok(key) => serde_json::json!({
|
||||
"status": "ok",
|
||||
"identity_key": hex::encode(&key),
|
||||
}),
|
||||
Err(e) => serde_json::json!({"error": format!("{e:#}")}),
|
||||
}
|
||||
}
|
||||
_ => serde_json::json!({"error": format!("unknown action: {action}")}),
|
||||
};
|
||||
|
||||
println!("{result}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
//! Ctrl+C / Ctrl+Q -- quit
|
||||
//!
|
||||
//! Feature gate: requires both `v2` and `tui` features.
|
||||
//!
|
||||
//! **Note:** Message display is currently local-only. Use the REPL client for
|
||||
//! end-to-end encrypted delivery. See `quicproquo-sdk::messaging` for the full pipeline.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -535,9 +538,11 @@ async fn handle_input(app: &mut TuiApp, client: &mut QpqClient, text: &str) {
|
||||
// Snap to bottom.
|
||||
app.scroll_offset = 0;
|
||||
|
||||
// TODO: actually send via SDK when the send pipeline is wired up.
|
||||
// For now, emit a notification.
|
||||
app.notification = Some(format!("Sent: {text}"));
|
||||
// NOTE: TUI message display is local-only. The full MLS encryption
|
||||
// pipeline (sealed sender + hybrid wrap + enqueue) is implemented in
|
||||
// quicproquo-sdk/src/messaging.rs but is not yet wired into the TUI.
|
||||
// Use the REPL client (`qpq repl`) for end-to-end message delivery.
|
||||
app.notification = Some("Message queued locally (TUI send not yet wired to SDK)".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "quicproquo-ffi"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "C FFI bindings for quicproquo messaging operations."
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
quicproquo-client = { path = "../quicproquo-client" }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
capnp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,428 +0,0 @@
|
||||
#![allow(unsafe_code)]
|
||||
//! quicproquo-ffi -- C FFI bindings for quicproquo messaging operations.
|
||||
//!
|
||||
//! Provides a synchronous C API that wraps the async quicproquo-client library.
|
||||
//! Each `QpqHandle` owns a Tokio runtime; FFI functions use `runtime.block_on()`
|
||||
//! to bridge from synchronous C callers to the async Rust internals.
|
||||
//!
|
||||
//! # Safety
|
||||
//!
|
||||
//! All FFI functions are `unsafe extern "C"` -- callers must ensure pointers
|
||||
//! are valid and strings are null-terminated UTF-8.
|
||||
|
||||
use std::ffi::{CStr, CString, c_char};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
// Status codes returned by FFI functions.
|
||||
pub const QPQ_OK: i32 = 0;
|
||||
pub const QPQ_ERROR: i32 = 1;
|
||||
pub const QPQ_AUTH_FAILED: i32 = 2;
|
||||
pub const QPQ_TIMEOUT: i32 = 3;
|
||||
pub const QPQ_NOT_CONNECTED: i32 = 4;
|
||||
|
||||
/// Opaque handle exposed to C callers via pointer.
|
||||
pub struct QpqHandle {
|
||||
runtime: Runtime,
|
||||
server: String,
|
||||
ca_cert: PathBuf,
|
||||
server_name: String,
|
||||
state_path: PathBuf,
|
||||
state_password: Option<String>,
|
||||
logged_in: bool,
|
||||
last_error: Option<CString>,
|
||||
}
|
||||
|
||||
impl QpqHandle {
|
||||
fn set_error(&mut self, msg: &str) {
|
||||
self.last_error = CString::new(msg).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Classify an `anyhow::Error` from `cmd_login` into an FFI status code.
|
||||
///
|
||||
/// Checks the error chain for typed downcasting before falling back to
|
||||
/// message-based heuristics.
|
||||
fn classify_login_error(err: &anyhow::Error) -> i32 {
|
||||
// Check error chain for OPAQUE-specific typed errors.
|
||||
for cause in err.chain() {
|
||||
// capnp::Error indicates transport/RPC failure.
|
||||
if cause.downcast_ref::<capnp::Error>().is_some() {
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
}
|
||||
// Fall back to message inspection for OPAQUE authentication failures,
|
||||
// since opaque-ke errors are converted to anyhow strings upstream.
|
||||
let msg = format!("{err:#}");
|
||||
if msg.contains("OPAQUE") || msg.contains("bad password") || msg.contains("credential") {
|
||||
QPQ_AUTH_FAILED
|
||||
} else {
|
||||
QPQ_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify an `anyhow::Error` from receive operations into an FFI status code.
|
||||
fn classify_receive_error(err: &anyhow::Error) -> i32 {
|
||||
let msg = format!("{err:#}");
|
||||
if msg.contains("timeout") || msg.contains("Timeout") || msg.contains("timed out") {
|
||||
QPQ_TIMEOUT
|
||||
} else {
|
||||
QPQ_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert a `*const c_char` to `&str`, returning `None` on null or invalid UTF-8.
|
||||
unsafe fn cstr_to_str<'a>(ptr: *const c_char) -> Option<&'a str> {
|
||||
if ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
CStr::from_ptr(ptr).to_str().ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a new handle and connect to the quicproquo server.
|
||||
///
|
||||
/// Returns a heap-allocated `QpqHandle` pointer on success, or null on failure.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `server`: server address as `host:port` (null-terminated UTF-8).
|
||||
/// - `ca_cert`: path to the CA certificate file (null-terminated UTF-8).
|
||||
/// - `server_name`: TLS server name (null-terminated UTF-8).
|
||||
///
|
||||
/// # Safety
|
||||
/// All pointer arguments must be valid, non-null, null-terminated C strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_connect(
|
||||
server: *const c_char,
|
||||
ca_cert: *const c_char,
|
||||
server_name: *const c_char,
|
||||
) -> *mut QpqHandle {
|
||||
let server_str = match cstr_to_str(server) {
|
||||
Some(s) => s,
|
||||
None => return std::ptr::null_mut(),
|
||||
};
|
||||
let ca_cert_str = match cstr_to_str(ca_cert) {
|
||||
Some(s) => s,
|
||||
None => return std::ptr::null_mut(),
|
||||
};
|
||||
let server_name_str = match cstr_to_str(server_name) {
|
||||
Some(s) => s,
|
||||
None => return std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let rt = match Runtime::new() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
// Verify connectivity by performing a health check.
|
||||
let ca_path = PathBuf::from(ca_cert_str);
|
||||
let connected = rt.block_on(async {
|
||||
quicproquo_client::cmd_health(server_str, &ca_path, server_name_str).await
|
||||
});
|
||||
|
||||
if let Err(e) = connected {
|
||||
// Cannot store error in handle since we failed to build one.
|
||||
eprintln!("qpq_connect: health check failed: {e}");
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
|
||||
// Derive a default state path from the server address.
|
||||
let state_path = PathBuf::from(format!("qpq-ffi-{server_str}.bin"));
|
||||
|
||||
let handle = Box::new(QpqHandle {
|
||||
runtime: rt,
|
||||
server: server_str.to_string(),
|
||||
ca_cert: ca_path,
|
||||
server_name: server_name_str.to_string(),
|
||||
state_path,
|
||||
state_password: None,
|
||||
logged_in: false,
|
||||
last_error: None,
|
||||
});
|
||||
Box::into_raw(handle)
|
||||
}
|
||||
|
||||
/// Authenticate with the server using OPAQUE (username + password).
|
||||
///
|
||||
/// On success the handle is marked as logged-in and subsequent send/receive
|
||||
/// calls will use the authenticated session.
|
||||
///
|
||||
/// Returns `QPQ_OK` on success, `QPQ_AUTH_FAILED` on bad credentials,
|
||||
/// or `QPQ_ERROR` on other failures.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
||||
/// - `username` and `password` must be valid null-terminated C strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_login(
|
||||
handle: *mut QpqHandle,
|
||||
username: *const c_char,
|
||||
password: *const c_char,
|
||||
) -> i32 {
|
||||
let h = match handle.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return QPQ_NOT_CONNECTED,
|
||||
};
|
||||
|
||||
let user = match cstr_to_str(username) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
h.set_error("invalid username pointer");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
let pass = match cstr_to_str(password) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
h.set_error("invalid password pointer");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
// Update state path to be username-specific.
|
||||
h.state_path = PathBuf::from(format!("qpq-ffi-{user}.bin"));
|
||||
|
||||
let result = h.runtime.block_on(async {
|
||||
quicproquo_client::cmd_login(
|
||||
&h.server,
|
||||
&h.ca_cert,
|
||||
&h.server_name,
|
||||
user,
|
||||
pass,
|
||||
None, // identity_key_hex
|
||||
Some(h.state_path.as_path()), // state_path
|
||||
h.state_password.as_deref(), // state_password
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
h.logged_in = true;
|
||||
QPQ_OK
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
let code = classify_login_error(&e);
|
||||
h.set_error(&msg);
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message to a recipient (by username).
|
||||
///
|
||||
/// The message is encrypted via MLS before delivery. The `message` buffer
|
||||
/// does not need to be null-terminated; `message_len` specifies its length.
|
||||
///
|
||||
/// Returns `QPQ_OK` on success.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
||||
/// - `recipient` must be a valid null-terminated C string.
|
||||
/// - `message` must point to at least `message_len` readable bytes.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_send(
|
||||
handle: *mut QpqHandle,
|
||||
recipient: *const c_char,
|
||||
message: *const u8,
|
||||
message_len: usize,
|
||||
) -> i32 {
|
||||
let h = match handle.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return QPQ_NOT_CONNECTED,
|
||||
};
|
||||
|
||||
if !h.logged_in {
|
||||
h.set_error("not logged in");
|
||||
return QPQ_NOT_CONNECTED;
|
||||
}
|
||||
|
||||
let rcpt = match cstr_to_str(recipient) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
h.set_error("invalid recipient pointer");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
if message.is_null() || message_len == 0 {
|
||||
h.set_error("empty message");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
let msg_bytes = std::slice::from_raw_parts(message, message_len);
|
||||
let msg_str = match std::str::from_utf8(msg_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
h.set_error(&format!("message is not valid UTF-8: {e}"));
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve recipient username to identity key, then send.
|
||||
let result = h.runtime.block_on(async {
|
||||
let node_client =
|
||||
quicproquo_client::connect_node(&h.server, &h.ca_cert, &h.server_name).await?;
|
||||
let peer_key = quicproquo_client::resolve_user(&node_client, rcpt)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("recipient '{rcpt}' not found"))?;
|
||||
let peer_key_hex = hex::encode(&peer_key);
|
||||
|
||||
quicproquo_client::cmd_send(
|
||||
&h.state_path,
|
||||
&h.server,
|
||||
&h.ca_cert,
|
||||
&h.server_name,
|
||||
Some(&peer_key_hex),
|
||||
false, // send_to_all
|
||||
msg_str,
|
||||
h.state_password.as_deref(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(()) => QPQ_OK,
|
||||
Err(e) => {
|
||||
h.set_error(&format!("{e:#}"));
|
||||
QPQ_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive pending messages, blocking up to `timeout_ms` milliseconds.
|
||||
///
|
||||
/// On success, `*out_json` is set to a heap-allocated null-terminated JSON
|
||||
/// string containing an array of received message objects. The caller must
|
||||
/// free this string with `qpq_free_string`.
|
||||
///
|
||||
/// Returns `QPQ_OK` on success (even if the array is empty),
|
||||
/// `QPQ_TIMEOUT` if the wait expires with no messages.
|
||||
///
|
||||
/// # Safety
|
||||
/// - `handle` must be a valid pointer from `qpq_connect`.
|
||||
/// - `out_json` must be a valid pointer to a `*mut c_char`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_receive(
|
||||
handle: *mut QpqHandle,
|
||||
timeout_ms: u32,
|
||||
out_json: *mut *mut c_char,
|
||||
) -> i32 {
|
||||
let h = match handle.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return QPQ_NOT_CONNECTED,
|
||||
};
|
||||
|
||||
if !h.logged_in {
|
||||
h.set_error("not logged in");
|
||||
return QPQ_NOT_CONNECTED;
|
||||
}
|
||||
|
||||
if out_json.is_null() {
|
||||
h.set_error("out_json is null");
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
|
||||
let result = h.runtime.block_on(async {
|
||||
quicproquo_client::receive_pending_plaintexts(
|
||||
&h.state_path,
|
||||
&h.server,
|
||||
&h.ca_cert,
|
||||
&h.server_name,
|
||||
timeout_ms as u64,
|
||||
h.state_password.as_deref(),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(plaintexts) => {
|
||||
// Convert raw byte payloads to a JSON array of base64 or lossy-UTF-8 strings.
|
||||
let messages: Vec<String> = plaintexts
|
||||
.iter()
|
||||
.map(|pt| String::from_utf8_lossy(pt).into_owned())
|
||||
.collect();
|
||||
|
||||
let json = match serde_json::to_string(&messages) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
h.set_error(&format!("JSON serialisation failed: {e}"));
|
||||
return QPQ_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
match CString::new(json) {
|
||||
Ok(cs) => {
|
||||
*out_json = cs.into_raw();
|
||||
QPQ_OK
|
||||
}
|
||||
Err(e) => {
|
||||
h.set_error(&format!("CString conversion failed: {e}"));
|
||||
QPQ_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
let code = classify_receive_error(&e);
|
||||
h.set_error(&msg);
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect and free the handle.
|
||||
///
|
||||
/// After this call, `handle` must not be used again.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid pointer from `qpq_connect`, or null (no-op).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_disconnect(handle: *mut QpqHandle) {
|
||||
if !handle.is_null() {
|
||||
let _ = Box::from_raw(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the last error message, or null if no error has been recorded.
|
||||
///
|
||||
/// The returned pointer is valid until the next FFI call on this handle.
|
||||
/// Do **not** free the returned pointer; it is owned by the handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid pointer from `qpq_connect`, or null (returns null).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_last_error(handle: *const QpqHandle) -> *const c_char {
|
||||
match handle.as_ref() {
|
||||
Some(h) => match &h.last_error {
|
||||
Some(cs) => cs.as_ptr(),
|
||||
None => std::ptr::null(),
|
||||
},
|
||||
None => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Free a string previously returned by `qpq_receive` (via `out_json`).
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must have been allocated by this library (via `CString::into_raw`),
|
||||
/// or null (no-op).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qpq_free_string(ptr: *mut c_char) {
|
||||
if !ptr.is_null() {
|
||||
let _ = CString::from_raw(ptr);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "quicproquo-gen"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Code generators for quicproquo — scaffold plugins, bots, RPC methods, and hooks."
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "qpq-gen"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true }
|
||||
@@ -1,212 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn generate(name: &str, output: &Path) -> Result<(), String> {
|
||||
let crate_name = sanitize_name(name);
|
||||
let dir = output.join(&crate_name);
|
||||
|
||||
if dir.exists() {
|
||||
return Err(format!("directory already exists: {}", dir.display()));
|
||||
}
|
||||
|
||||
let src_dir = dir.join("src");
|
||||
fs::create_dir_all(&src_dir).map_err(|e| format!("create dir: {e}"))?;
|
||||
|
||||
// Cargo.toml
|
||||
let cargo_toml = format!(
|
||||
r#"[package]
|
||||
name = "{crate_name}"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "quicproquo bot: {name}"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
quicproquo-bot = {{ git = "https://github.com/nickvidal/quicproquo" }}
|
||||
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
|
||||
"#,
|
||||
crate_name = crate_name,
|
||||
name = name,
|
||||
);
|
||||
write_file(&dir.join("Cargo.toml"), &cargo_toml)?;
|
||||
|
||||
// src/main.rs
|
||||
let main_rs = format!(
|
||||
r#"//! quicproquo bot: {name}
|
||||
//!
|
||||
//! A bot that connects to a quicproquo server and responds to messages.
|
||||
//!
|
||||
//! Usage:
|
||||
//! {crate_name} --server 127.0.0.1:7000 --username my-bot --password secret
|
||||
//!
|
||||
//! Environment variables (alternative to CLI args):
|
||||
//! QPQ_SERVER, QPQ_USERNAME, QPQ_PASSWORD, QPQ_CA_CERT, QPQ_STATE_PATH
|
||||
|
||||
use quicproquo_bot::{{Bot, BotConfig}};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {{
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
// --- Configuration ---
|
||||
let server = env_or("QPQ_SERVER", "127.0.0.1:7000");
|
||||
let username = env_or("QPQ_USERNAME", "{crate_name}");
|
||||
let password = env_or("QPQ_PASSWORD", "changeme");
|
||||
let ca_cert = env_or("QPQ_CA_CERT", "server-cert.der");
|
||||
let state_path = env_or("QPQ_STATE_PATH", "{crate_name}-state.bin");
|
||||
|
||||
let config = BotConfig::new(&server, &username, &password)
|
||||
.ca_cert(&ca_cert)
|
||||
.state_path(&state_path);
|
||||
|
||||
// --- Connect and authenticate ---
|
||||
tracing::info!("connecting to {{server}} as {{username}}...");
|
||||
let bot = Bot::connect(config).await?;
|
||||
tracing::info!("authenticated as {{}} (key: {{}})", bot.username(), &bot.identity_key_hex()[..16]);
|
||||
|
||||
// --- Main loop: poll for messages and respond ---
|
||||
tracing::info!("listening for messages (Ctrl+C to stop)...");
|
||||
loop {{
|
||||
let messages = bot.receive(5000).await?;
|
||||
for msg in messages {{
|
||||
tracing::info!("[{{}}] {{}}", msg.sender, msg.text);
|
||||
|
||||
// --- Add your command handlers here ---
|
||||
if let Some(response) = handle_message(&msg.sender, &msg.text) {{
|
||||
bot.send_dm(&msg.sender, &response).await?;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Process an incoming message and optionally return a response.
|
||||
///
|
||||
/// Add your bot's command logic here.
|
||||
fn handle_message(sender: &str, text: &str) -> Option<String> {{
|
||||
let text = text.trim();
|
||||
|
||||
// !help — list available commands
|
||||
if text == "!help" {{
|
||||
return Some(
|
||||
"Available commands:\n\
|
||||
!help — show this message\n\
|
||||
!echo <text> — echo back the text\n\
|
||||
!whoami — show your username\n\
|
||||
!ping — pong!"
|
||||
.to_string(),
|
||||
);
|
||||
}}
|
||||
|
||||
// !echo <text> — echo back
|
||||
if let Some(rest) = text.strip_prefix("!echo ") {{
|
||||
return Some(rest.to_string());
|
||||
}}
|
||||
|
||||
// !whoami — tell the sender their username
|
||||
if text == "!whoami" {{
|
||||
return Some(format!("You are {{sender}}"));
|
||||
}}
|
||||
|
||||
// !ping — respond with pong
|
||||
if text == "!ping" {{
|
||||
return Some("pong!".to_string());
|
||||
}}
|
||||
|
||||
// Unknown command or regular message — no response
|
||||
None
|
||||
}}
|
||||
|
||||
fn env_or(key: &str, default: &str) -> String {{
|
||||
std::env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}}
|
||||
"#,
|
||||
name = name,
|
||||
crate_name = crate_name,
|
||||
);
|
||||
write_file(&src_dir.join("main.rs"), &main_rs)?;
|
||||
|
||||
// README
|
||||
let readme = format!(
|
||||
r#"# {name} — quicproquo bot
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo build
|
||||
|
||||
# Run (make sure a qpq server is running)
|
||||
QPQ_SERVER=127.0.0.1:7000 \
|
||||
QPQ_USERNAME={crate_name} \
|
||||
QPQ_PASSWORD=changeme \
|
||||
QPQ_CA_CERT=path/to/server-cert.der \
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!help` | Show available commands |
|
||||
| `!echo <text>` | Echo back the text |
|
||||
| `!whoami` | Show your username |
|
||||
| `!ping` | Respond with "pong!" |
|
||||
|
||||
## Adding commands
|
||||
|
||||
Edit the `handle_message` function in `src/main.rs`:
|
||||
|
||||
```rust
|
||||
fn handle_message(sender: &str, text: &str) -> Option<String> {{
|
||||
if text == "!mycommand" {{
|
||||
return Some("my response".to_string());
|
||||
}}
|
||||
None
|
||||
}}
|
||||
```
|
||||
|
||||
## Pipe mode
|
||||
|
||||
For shell integration, use the Bot SDK's JSON pipe mode:
|
||||
|
||||
```bash
|
||||
echo '{{"action":"send","to":"alice","text":"hello"}}' | my-bot
|
||||
echo '{{"action":"recv","timeout_ms":5000}}' | my-bot
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Bot SDK docs](https://github.com/nickvidal/quicproquo/blob/main/docs/src/getting-started/bot-sdk.md)
|
||||
- [Server Hooks](https://github.com/nickvidal/quicproquo/blob/main/docs/src/internals/server-hooks.md)
|
||||
"#,
|
||||
name = name,
|
||||
crate_name = crate_name,
|
||||
);
|
||||
write_file(&dir.join("README.md"), &readme)?;
|
||||
|
||||
println!("Created bot project: {}", dir.display());
|
||||
println!();
|
||||
println!(" cd {crate_name}");
|
||||
println!(" # Edit src/main.rs to add your commands");
|
||||
println!(" QPQ_SERVER=127.0.0.1:7000 QPQ_PASSWORD=secret cargo run");
|
||||
println!();
|
||||
println!("The bot responds to !help, !echo, !whoami, !ping out of the box.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.replace(['-', ' '], "_")
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, content: &str) -> Result<(), String> {
|
||||
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
pub fn generate(name: &str) -> Result<(), String> {
|
||||
let snake = name.to_lowercase().replace(['-', ' '], "_");
|
||||
let pascal = to_pascal_case(&snake);
|
||||
|
||||
println!("=== Adding hook event: on_{snake} ===");
|
||||
println!();
|
||||
println!("Follow these steps to add a new `on_{snake}` hook event.");
|
||||
println!();
|
||||
|
||||
// Step 1: Event struct
|
||||
println!("--- Step 1: Event struct ---");
|
||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
||||
println!();
|
||||
println!(
|
||||
r#"/// Event data for {snake} operations.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct {pascal}Event {{
|
||||
// TODO: add your event fields here
|
||||
// Example:
|
||||
// pub channel_id: Vec<u8>,
|
||||
// pub user_key: Vec<u8>,
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 2: Trait method
|
||||
println!("--- Step 2: Trait method ---");
|
||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
||||
println!();
|
||||
println!("Add to the `ServerHooks` trait:");
|
||||
println!();
|
||||
println!(
|
||||
r#" /// Called when {snake} occurs.
|
||||
fn on_{snake}(&self, _event: &{pascal}Event) {{
|
||||
// Default: no-op
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 3: TracingHooks implementation
|
||||
println!("--- Step 3: TracingHooks implementation ---");
|
||||
println!("File: crates/quicproquo-server/src/hooks.rs");
|
||||
println!();
|
||||
println!("Add to `impl ServerHooks for TracingHooks`:");
|
||||
println!();
|
||||
println!(
|
||||
r#" fn on_{snake}(&self, _event: &{pascal}Event) {{
|
||||
tracing::info!("hook: {snake}");
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 4: Plugin API (C-compatible struct)
|
||||
println!("--- Step 4: Plugin API ---");
|
||||
println!("File: crates/quicproquo-plugin-api/src/lib.rs");
|
||||
println!();
|
||||
println!("Add a C-compatible event struct:");
|
||||
println!();
|
||||
println!(
|
||||
r#"#[repr(C)]
|
||||
pub struct C{pascal}Event {{
|
||||
// TODO: mirror the fields from {pascal}Event using C-compatible types
|
||||
// Use *const u8 + len for byte slices, *const c_char for strings
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
println!("Add to `HookVTable`:");
|
||||
println!();
|
||||
println!(
|
||||
r#" pub on_{snake}: Option<extern "C" fn(*mut c_void, *const C{pascal}Event)>,
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 5: Wire into PluginHooks
|
||||
println!("--- Step 5: PluginHooks dispatch ---");
|
||||
println!("File: crates/quicproquo-server/src/plugin_loader.rs");
|
||||
println!();
|
||||
println!("Add to `impl ServerHooks for PluginHooks`:");
|
||||
println!();
|
||||
println!(
|
||||
r#" fn on_{snake}(&self, event: &{pascal}Event) {{
|
||||
if let Some(hook_fn) = self.vtable.on_{snake} {{
|
||||
let c_event = C{pascal}Event {{
|
||||
// TODO: convert fields
|
||||
}};
|
||||
hook_fn(self.vtable.user_data, &c_event);
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 6: Call the hook
|
||||
println!("--- Step 6: Call the hook in the RPC handler ---");
|
||||
println!("In the relevant handler file under crates/quicproquo-server/src/node_service/:");
|
||||
println!();
|
||||
println!(
|
||||
r#" use crate::hooks::{pascal}Event;
|
||||
|
||||
// At the appropriate point in the handler:
|
||||
self.hooks.on_{snake}(&{pascal}Event {{
|
||||
// fill in fields
|
||||
}});
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 7: Verify
|
||||
println!("--- Step 7: Verify ---");
|
||||
println!(" cargo build -p quicproquo-plugin-api");
|
||||
println!(" cargo build -p quicproquo-server");
|
||||
println!(" cargo test -p quicproquo-server");
|
||||
println!();
|
||||
|
||||
// Summary
|
||||
println!("=== Files to modify ===");
|
||||
println!(" [modify] crates/quicproquo-server/src/hooks.rs");
|
||||
println!(" [modify] crates/quicproquo-plugin-api/src/lib.rs");
|
||||
println!(" [modify] crates/quicproquo-server/src/plugin_loader.rs");
|
||||
println!(" [modify] crates/quicproquo-server/src/node_service/<handler>.rs");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_pascal_case(snake: &str) -> String {
|
||||
snake
|
||||
.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod bot;
|
||||
pub mod hook;
|
||||
pub mod plugin;
|
||||
pub mod rpc;
|
||||
@@ -1,186 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn generate(name: &str, output: &Path) -> Result<(), String> {
|
||||
let crate_name = sanitize_name(name);
|
||||
let dir = output.join(&crate_name);
|
||||
|
||||
if dir.exists() {
|
||||
return Err(format!("directory already exists: {}", dir.display()));
|
||||
}
|
||||
|
||||
let src_dir = dir.join("src");
|
||||
fs::create_dir_all(&src_dir).map_err(|e| format!("create dir: {e}"))?;
|
||||
|
||||
// Cargo.toml
|
||||
let cargo_toml = format!(
|
||||
r#"[package]
|
||||
name = "{crate_name}"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "quicproquo server plugin: {name}"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# Empty workspace — this plugin builds independently of the qpq workspace.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
quicproquo-plugin-api = {{ git = "https://github.com/nickvidal/quicproquo", default-features = false }}
|
||||
"#,
|
||||
crate_name = crate_name,
|
||||
name = name,
|
||||
);
|
||||
write_file(&dir.join("Cargo.toml"), &cargo_toml)?;
|
||||
|
||||
// src/lib.rs
|
||||
let lib_rs = format!(
|
||||
r#"//! quicproquo server plugin: {name}
|
||||
//!
|
||||
//! Build with: cargo build --release
|
||||
//! Install: cp target/release/lib{crate_name}.so /path/to/plugins/
|
||||
//! The server loads it automatically when started with --plugin-dir.
|
||||
|
||||
use quicproquo_plugin_api::{{HookVTable, CMessageEvent, HOOK_CONTINUE, HOOK_REJECT, PLUGIN_OK}};
|
||||
use std::ffi::CString;
|
||||
use std::os::raw::c_int;
|
||||
|
||||
/// Plugin state — allocate on the heap in init, free in destroy.
|
||||
struct PluginState {{
|
||||
/// Example: maximum allowed payload size in bytes.
|
||||
max_payload_bytes: usize,
|
||||
/// Stored rejection message (must outlive the hook call).
|
||||
reject_msg: Option<CString>,
|
||||
}}
|
||||
|
||||
/// Called by the server on plugin load.
|
||||
///
|
||||
/// Fill the vtable with your hook implementations. Return PLUGIN_OK on success.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn qpq_plugin_init(vtable: *mut HookVTable) -> c_int {{
|
||||
let state = Box::new(PluginState {{
|
||||
max_payload_bytes: 1_000_000, // 1 MB limit
|
||||
reject_msg: None,
|
||||
}});
|
||||
|
||||
let vt = unsafe {{ &mut *vtable }};
|
||||
vt.user_data = Box::into_raw(state) as *mut _;
|
||||
vt.on_message_enqueue = Some(on_message_enqueue);
|
||||
vt.error_message = Some(error_message);
|
||||
vt.destroy = Some(destroy);
|
||||
|
||||
eprintln!("[{name}] plugin loaded");
|
||||
PLUGIN_OK
|
||||
}}
|
||||
|
||||
/// Hook: called before each message is stored in the delivery queue.
|
||||
///
|
||||
/// Return HOOK_CONTINUE to allow, HOOK_REJECT to block.
|
||||
extern "C" fn on_message_enqueue(
|
||||
user_data: *mut std::ffi::c_void,
|
||||
event: *const CMessageEvent,
|
||||
) -> c_int {{
|
||||
let state = unsafe {{ &mut *(user_data as *mut PluginState) }};
|
||||
let event = unsafe {{ &*event }};
|
||||
|
||||
if event.payload_len > state.max_payload_bytes {{
|
||||
let msg = format!(
|
||||
"payload too large: {{}} > {{}} bytes",
|
||||
event.payload_len, state.max_payload_bytes
|
||||
);
|
||||
state.reject_msg = CString::new(msg).ok();
|
||||
return HOOK_REJECT;
|
||||
}}
|
||||
|
||||
HOOK_CONTINUE
|
||||
}}
|
||||
|
||||
/// Return a pointer to the rejection error message (valid until next hook call).
|
||||
extern "C" fn error_message(
|
||||
user_data: *mut std::ffi::c_void,
|
||||
) -> *const std::os::raw::c_char {{
|
||||
let state = unsafe {{ &*(user_data as *const PluginState) }};
|
||||
match &state.reject_msg {{
|
||||
Some(msg) => msg.as_ptr(),
|
||||
None => std::ptr::null(),
|
||||
}}
|
||||
}}
|
||||
|
||||
/// Cleanup: free the plugin state.
|
||||
extern "C" fn destroy(user_data: *mut std::ffi::c_void) {{
|
||||
if !user_data.is_null() {{
|
||||
unsafe {{ drop(Box::from_raw(user_data as *mut PluginState)) }};
|
||||
}}
|
||||
eprintln!("[{name}] plugin unloaded");
|
||||
}}
|
||||
"#,
|
||||
name = name,
|
||||
crate_name = crate_name,
|
||||
);
|
||||
write_file(&src_dir.join("lib.rs"), &lib_rs)?;
|
||||
|
||||
// README
|
||||
let readme = format!(
|
||||
r#"# {name} — quicproquo server plugin
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Copy the shared library to the server's plugin directory:
|
||||
|
||||
```bash
|
||||
cp target/release/lib{crate_name}.so /path/to/plugins/
|
||||
```
|
||||
|
||||
Start the server with:
|
||||
|
||||
```bash
|
||||
qpq-server --plugin-dir /path/to/plugins/
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
This plugin implements `on_message_enqueue` to reject oversized payloads.
|
||||
Edit `src/lib.rs` to add your own logic. Available hooks:
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `on_message_enqueue` | Inspect/reject messages before delivery (return `HOOK_REJECT`) |
|
||||
| `on_batch_enqueue` | Observe batch message delivery |
|
||||
| `on_auth` | Observe login success/failure |
|
||||
| `on_channel_created` | Observe channel creation |
|
||||
| `on_fetch` | Observe message fetch operations |
|
||||
| `on_user_registered` | Observe new user registration |
|
||||
|
||||
See the [Server Hooks documentation](https://github.com/nickvidal/quicproquo/blob/main/docs/src/internals/server-hooks.md) for details.
|
||||
"#,
|
||||
name = name,
|
||||
crate_name = crate_name,
|
||||
);
|
||||
write_file(&dir.join("README.md"), &readme)?;
|
||||
|
||||
println!("Created plugin project: {}", dir.display());
|
||||
println!();
|
||||
println!(" cd {crate_name}");
|
||||
println!(" cargo build --release");
|
||||
println!(" cp target/release/lib{crate_name}.so /path/to/plugins/");
|
||||
println!();
|
||||
println!("Edit src/lib.rs to implement your hook logic.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.replace(['-', ' '], "_")
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, content: &str) -> Result<(), String> {
|
||||
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
pub fn generate(name: &str) -> Result<(), String> {
|
||||
let snake = to_snake_case(name);
|
||||
let camel = name.to_string();
|
||||
println!("=== Adding RPC method: {camel} ===");
|
||||
println!();
|
||||
println!("Follow these steps to add a new `{camel}` RPC method.");
|
||||
println!("Each step shows the file and the code to add.");
|
||||
println!();
|
||||
|
||||
// Step 1: Schema
|
||||
println!("--- Step 1: Cap'n Proto schema ---");
|
||||
println!("File: schemas/node.capnp");
|
||||
println!();
|
||||
println!("Add to the `interface NodeService` block:");
|
||||
println!();
|
||||
println!(
|
||||
r#" {camel} @N (auth :AuthContext, <your params here>) -> (<your results here>);
|
||||
"#,
|
||||
);
|
||||
println!(" (Replace @N with the next ordinal number in the interface.)");
|
||||
println!();
|
||||
println!("Then rebuild the proto crate:");
|
||||
println!(" cargo build -p quicproquo-proto");
|
||||
println!();
|
||||
|
||||
// Step 2: Handler module
|
||||
println!("--- Step 2: Handler module ---");
|
||||
println!("File: crates/quicproquo-server/src/node_service/{snake}.rs");
|
||||
println!();
|
||||
println!(
|
||||
r#"use capnp::capability::Promise;
|
||||
use quicproquo_proto::node_capnp::node_service;
|
||||
|
||||
use crate::auth::{{coded_error, validate_auth_context}};
|
||||
use crate::error_codes::*;
|
||||
|
||||
use super::NodeServiceImpl;
|
||||
|
||||
impl NodeServiceImpl {{
|
||||
pub fn handle_{snake}(
|
||||
&mut self,
|
||||
params: node_service::{camel}Params,
|
||||
mut results: node_service::{camel}Results,
|
||||
) -> Promise<(), capnp::Error> {{
|
||||
let p = match params.get() {{
|
||||
Ok(p) => p,
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
}};
|
||||
let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) {{
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => return Promise::err(e),
|
||||
}};
|
||||
|
||||
// TODO: implement your logic here
|
||||
|
||||
Promise::ok(())
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 3: Wire into mod.rs
|
||||
println!("--- Step 3: Register in mod.rs ---");
|
||||
println!("File: crates/quicproquo-server/src/node_service/mod.rs");
|
||||
println!();
|
||||
println!("Add to the module declarations at the top:");
|
||||
println!(" mod {snake};");
|
||||
println!();
|
||||
println!("Add to the `impl node_service::Server for NodeServiceImpl` block:");
|
||||
println!();
|
||||
println!(
|
||||
r#" fn {snake}(
|
||||
&mut self,
|
||||
params: node_service::{camel}Params,
|
||||
results: node_service::{camel}Results,
|
||||
) -> capnp::capability::Promise<(), capnp::Error> {{
|
||||
self.handle_{snake}(params, results)
|
||||
}}
|
||||
"#,
|
||||
);
|
||||
|
||||
// Step 4: Storage (if needed)
|
||||
println!("--- Step 4: Storage trait (if needed) ---");
|
||||
println!("File: crates/quicproquo-server/src/storage.rs");
|
||||
println!();
|
||||
println!("If your RPC method needs persistent storage, add a method to the Store trait:");
|
||||
println!();
|
||||
println!(
|
||||
r#" fn {snake}(&self, /* params */) -> Result</* return */, StorageError>;
|
||||
"#,
|
||||
);
|
||||
println!("Then implement it in:");
|
||||
println!(" - crates/quicproquo-server/src/sql_store.rs (SQLite backend)");
|
||||
println!(" - crates/quicproquo-server/src/storage.rs (FileBackedStore)");
|
||||
println!();
|
||||
|
||||
// Step 5: Hook (if needed)
|
||||
println!("--- Step 5: Hook event (optional) ---");
|
||||
println!("If you want plugins to observe this RPC, run:");
|
||||
println!(" qpq-gen hook {snake}");
|
||||
println!();
|
||||
|
||||
// Step 6: Verify
|
||||
println!("--- Step 6: Verify ---");
|
||||
println!(" cargo build -p quicproquo-server");
|
||||
println!(" cargo test -p quicproquo-server");
|
||||
println!();
|
||||
|
||||
// Summary
|
||||
println!("=== Files to create/modify ===");
|
||||
println!(" [modify] schemas/node.capnp");
|
||||
println!(" [create] crates/quicproquo-server/src/node_service/{snake}.rs");
|
||||
println!(" [modify] crates/quicproquo-server/src/node_service/mod.rs");
|
||||
println!(" [modify] crates/quicproquo-server/src/storage.rs (if needed)");
|
||||
println!(" [modify] crates/quicproquo-server/src/sql_store.rs (if needed)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_snake_case(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len() + 4);
|
||||
for (i, ch) in s.chars().enumerate() {
|
||||
if ch.is_uppercase() && i > 0 {
|
||||
result.push('_');
|
||||
}
|
||||
result.push(ch.to_ascii_lowercase());
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod generators;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "qpq-gen", about = "Code generators for quicproquo")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Scaffold a new server plugin (dynamic .so/.dylib)
|
||||
Plugin {
|
||||
/// Plugin name (e.g. "rate-limiter", "audit-log")
|
||||
name: String,
|
||||
/// Output directory (default: current directory)
|
||||
#[arg(short, long, default_value = ".")]
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Scaffold a new bot project using the Bot SDK
|
||||
Bot {
|
||||
/// Bot name (e.g. "echo-bot", "moderation-bot")
|
||||
name: String,
|
||||
/// Output directory (default: current directory)
|
||||
#[arg(short, long, default_value = ".")]
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Show instructions for adding a new Cap'n Proto RPC method
|
||||
Rpc {
|
||||
/// RPC method name in camelCase (e.g. "listChannels")
|
||||
name: String,
|
||||
},
|
||||
/// Show instructions for adding a new server hook event
|
||||
Hook {
|
||||
/// Hook event name in snake_case (e.g. "message_deleted")
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let result = match cli.command {
|
||||
Command::Plugin { name, output } => generators::plugin::generate(&name, &output),
|
||||
Command::Bot { name, output } => generators::bot::generate(&name, &output),
|
||||
Command::Rpc { name } => generators::rpc::generate(&name),
|
||||
Command::Hook { name } => generators::hook::generate(&name),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "quicproquo-gui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Native GUI for quicproquo (Tauri 2)."
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "qpq-gui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
quicproquo-core = { path = "../quicproquo-core" }
|
||||
quicproquo-client = { path = "../quicproquo-client" }
|
||||
quicproquo-proto = { path = "../quicproquo-proto" }
|
||||
tauri = { version = "2", features = [] }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = "2"
|
||||
@@ -1,32 +0,0 @@
|
||||
# quicproquo-gui
|
||||
|
||||
Native GUI for quicproquo using [Tauri 2](https://v2.tauri.app/). The UI runs in a webview; all server-facing work (capnp-rpc, `node_service::Client`) runs on a **dedicated backend thread** with a tokio `LocalSet`, since that code is `!Send`.
|
||||
|
||||
## Backend threading model
|
||||
|
||||
- A single **backend thread** runs a tokio `LocalSet` and a request-response loop.
|
||||
- The UI thread sends commands over an `mpsc` channel: `Whoami { state_path, password }` or `Health { server, ca_cert, server_name }`.
|
||||
- For each request, the backend runs sync code (whoami) or `LocalSet::run_until(async { ... })` (health). It then sends `Result<String, String>` back on the provided reply channel.
|
||||
- Tauri commands (`whoami`, `health`) block on that reply so the frontend gets a simple async-style result.
|
||||
|
||||
## How to run
|
||||
|
||||
From the workspace root:
|
||||
|
||||
```bash
|
||||
cargo run -p quicproquo-gui
|
||||
```
|
||||
|
||||
**Linux:** Tauri uses GTK. Install development packages if the build fails, e.g.:
|
||||
|
||||
- Debian/Ubuntu: `sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev`
|
||||
- Fedora: `sudo dnf install gtk3-devel webkit2gtk4.1-devel`
|
||||
|
||||
## Frontend
|
||||
|
||||
The frontend is static HTML in `ui/index.html` (no npm or build step). It provides:
|
||||
|
||||
- **Whoami** – state path (and optional password); calls `whoami` and shows JSON (identity_key, fingerprint, etc.).
|
||||
- **Health** – server address; calls `health` and shows server status and RTT JSON.
|
||||
|
||||
Default CA cert and server name for health are the same as the CLI (`data/server-cert.der`, `localhost`) unless overridden via optional params.
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2/capability",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window (custom commands whoami, health are allowed by default)",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window (custom commands whoami, health are allowed by default)","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-close","core:window:allow-set-title"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,86 +0,0 @@
|
||||
//! Backend service running on a dedicated thread with a tokio LocalSet.
|
||||
//!
|
||||
//! All server-facing work (capnp-rpc, node_service::Client) is !Send and must run on this
|
||||
//! single thread. The UI thread sends commands over a channel; this thread runs
|
||||
//! `LocalSet::run_until` for each request and sends the result back.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::task::LocalSet;
|
||||
|
||||
use quicproquo_client::{cmd_health_json, whoami_json};
|
||||
|
||||
/// Commands the UI can send to the backend thread.
|
||||
pub enum BackendCommand {
|
||||
Whoami {
|
||||
state_path: String,
|
||||
password: Option<String>,
|
||||
},
|
||||
Health {
|
||||
server: String,
|
||||
ca_cert: PathBuf,
|
||||
server_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Response sent back to the UI.
|
||||
pub type BackendResponse = Result<String, String>;
|
||||
|
||||
/// Spawn the backend thread and return a sender to post commands and a join handle.
|
||||
/// The backend runs a tokio LocalSet and processes one command at a time:
|
||||
/// for each received command it runs `LocalSet::run_until(future)` (for async commands)
|
||||
/// or runs sync code (whoami), then sends the result on the provided reply channel.
|
||||
pub fn spawn_backend() -> (mpsc::Sender<(BackendCommand, mpsc::Sender<BackendResponse>)>, thread::JoinHandle<()>) {
|
||||
let (tx, rx) = mpsc::channel::<(BackendCommand, mpsc::Sender<BackendResponse>)>();
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let rt = Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("backend tokio runtime");
|
||||
let local = LocalSet::new();
|
||||
|
||||
while let Ok((cmd, reply_tx)) = rx.recv() {
|
||||
let result = run_command(&local, &rt, cmd);
|
||||
let _ = reply_tx.send(result);
|
||||
}
|
||||
});
|
||||
|
||||
(tx, handle)
|
||||
}
|
||||
|
||||
fn run_command(
|
||||
local: &LocalSet,
|
||||
rt: &tokio::runtime::Runtime,
|
||||
cmd: BackendCommand,
|
||||
) -> BackendResponse {
|
||||
match cmd {
|
||||
BackendCommand::Whoami { state_path, password } => {
|
||||
let path = PathBuf::from(&state_path);
|
||||
whoami_json(&path, password.as_deref()).map_err(|e| e.to_string())
|
||||
}
|
||||
BackendCommand::Health {
|
||||
server,
|
||||
ca_cert,
|
||||
server_name,
|
||||
} => {
|
||||
// Request-response: we run LocalSet::run_until for this single request so capnp-rpc
|
||||
// and connect_node stay on this thread (!Send).
|
||||
let fut = cmd_health_json(&server, &ca_cert, &server_name);
|
||||
rt.block_on(local.run_until(fut)).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default CA cert path (relative to cwd or absolute); same default as CLI.
|
||||
pub fn default_ca_cert() -> PathBuf {
|
||||
PathBuf::from("data/server-cert.der")
|
||||
}
|
||||
|
||||
/// Default TLS server name.
|
||||
pub fn default_server_name() -> String {
|
||||
"localhost".to_string()
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! quicproquo native GUI (Tauri 2).
|
||||
//!
|
||||
//! The backend runs on a dedicated thread with a tokio LocalSet; all server-facing
|
||||
//! work (capnp-rpc, node_service::Client) is dispatched there. Tauri commands
|
||||
//! block on the request-response channel until the backend returns.
|
||||
|
||||
mod backend;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use backend::{spawn_backend, BackendCommand};
|
||||
|
||||
/// Shared state: sender to the backend thread.
|
||||
struct BackendState {
|
||||
tx: mpsc::Sender<(BackendCommand, mpsc::Sender<backend::BackendResponse>)>,
|
||||
}
|
||||
|
||||
/// Runs whoami on the backend thread and returns JSON string (identity_key, fingerprint, etc.).
|
||||
#[tauri::command]
|
||||
fn whoami(
|
||||
state: tauri::State<BackendState>,
|
||||
state_path: String,
|
||||
password: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
state
|
||||
.tx
|
||||
.send((
|
||||
BackendCommand::Whoami {
|
||||
state_path,
|
||||
password,
|
||||
},
|
||||
reply_tx,
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
reply_rx.recv().map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Runs health check on the backend thread (LocalSet::run_until) and returns status JSON.
|
||||
#[tauri::command]
|
||||
fn health(
|
||||
state: tauri::State<BackendState>,
|
||||
server: String,
|
||||
ca_cert: Option<String>,
|
||||
server_name: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let ca_cert = ca_cert
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(backend::default_ca_cert);
|
||||
let server_name = server_name.unwrap_or_else(backend::default_server_name);
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
state
|
||||
.tx
|
||||
.send((
|
||||
BackendCommand::Health {
|
||||
server,
|
||||
ca_cert,
|
||||
server_name,
|
||||
},
|
||||
reply_tx,
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
reply_rx.recv().map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let (backend_tx, _backend_handle) = spawn_backend();
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(BackendState { tx: backend_tx })
|
||||
.invoke_handler(tauri::generate_handler![whoami, health])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! Desktop entry point for quicproquo-gui.
|
||||
|
||||
fn main() {
|
||||
quicproquo_gui::run()
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "qpq-gui",
|
||||
"identifier": "chat.quicproquo.gui",
|
||||
"build": {
|
||||
"frontendDist": "./ui",
|
||||
"beforeBuildCommand": "",
|
||||
"beforeDevCommand": ""
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "quicproquo",
|
||||
"width": 640,
|
||||
"height": 480
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {},
|
||||
"plugins": {}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>quicproquo</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 1rem; }
|
||||
button { margin: 0.25rem; padding: 0.5rem 1rem; cursor: pointer; }
|
||||
#output { white-space: pre-wrap; background: #f0f0f0; padding: 0.75rem; margin-top: 1rem; min-height: 4rem; border-radius: 4px; }
|
||||
.error { color: #c00; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>quicproquo</h1>
|
||||
<p>
|
||||
<button id="whoami">Whoami</button>
|
||||
<button id="health">Health</button>
|
||||
</p>
|
||||
<label>State path: <input id="statePath" type="text" value="qpq-state.bin" size="32" /></label>
|
||||
<br />
|
||||
<label>Server: <input id="server" type="text" value="127.0.0.1:7000" size="24" /></label>
|
||||
<div id="output">Click Whoami or Health. Results appear here.</div>
|
||||
|
||||
<script>
|
||||
const output = document.getElementById('output');
|
||||
const statePath = document.getElementById('statePath');
|
||||
const server = document.getElementById('server');
|
||||
|
||||
function show(result, isError = false) {
|
||||
output.textContent = result;
|
||||
output.className = isError ? 'error' : '';
|
||||
}
|
||||
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
show('Tauri API not available (not running inside Tauri?).', true);
|
||||
} else {
|
||||
document.getElementById('whoami').addEventListener('click', function () {
|
||||
show('Running whoami…');
|
||||
invoke('whoami', { statePath: statePath.value.trim(), password: null })
|
||||
.then(function (s) { show(s); })
|
||||
.catch(function (e) { show(String(e), true); });
|
||||
});
|
||||
document.getElementById('health').addEventListener('click', function () {
|
||||
show('Running health…');
|
||||
invoke('health', { server: server.value.trim() })
|
||||
.then(function (s) { show(s); })
|
||||
.catch(function (e) { show(String(e), true); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,32 +0,0 @@
|
||||
[package]
|
||||
name = "quicproquo-mobile"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "C FFI layer for quicproquo, proving QUIC connection migration."
|
||||
license = "MIT"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable SkipServerVerification for development/testing only.
|
||||
# NEVER enable in production — this disables TLS certificate validation.
|
||||
insecure-dev = []
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
|
||||
# QUIC
|
||||
quinn = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
|
||||
# TLS root certificates (used when insecure-dev is NOT enabled)
|
||||
webpki-roots = "0.26"
|
||||
|
||||
# Error handling
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rcgen = { workspace = true }
|
||||
@@ -1,433 +0,0 @@
|
||||
//! quicproquo-mobile — C FFI layer for mobile integration.
|
||||
//!
|
||||
//! Provides a minimal C API that proves QUIC connection migration works
|
||||
//! (wifi → cellular handoff without message loss). Each FFI function uses
|
||||
//! `runtime.block_on(local.run_until(...))` to satisfy capnp-rpc's `!Send`
|
||||
//! requirement.
|
||||
//!
|
||||
//! # Safety
|
||||
//!
|
||||
//! All FFI functions are `unsafe extern "C"` — callers must ensure pointers
|
||||
//! are valid and buffers are correctly sized.
|
||||
|
||||
use std::ffi::c_char;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use quinn::Endpoint;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
/// Opaque handle returned by `qnpc_connect`.
|
||||
#[allow(dead_code)]
|
||||
pub struct MobileHandle {
|
||||
runtime: Runtime,
|
||||
endpoint: Endpoint,
|
||||
connection: Option<quinn::Connection>,
|
||||
server_addr: SocketAddr,
|
||||
server_name: String,
|
||||
}
|
||||
|
||||
/// Status codes returned by FFI functions.
|
||||
#[repr(C)]
|
||||
pub enum QnpcStatus {
|
||||
Ok = 0,
|
||||
Error = 1,
|
||||
Timeout = 2,
|
||||
NotConnected = 3,
|
||||
}
|
||||
|
||||
/// Connect to a quicproquo server. Returns a handle pointer (null on failure).
|
||||
///
|
||||
/// # Safety
|
||||
/// `server_addr` and `server_name` must be valid null-terminated C strings.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qnpc_connect(
|
||||
server_addr: *const c_char,
|
||||
server_name: *const c_char,
|
||||
) -> *mut MobileHandle {
|
||||
let addr_str = match std::ffi::CStr::from_ptr(server_addr).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
let name_str = match std::ffi::CStr::from_ptr(server_name).to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let addr: SocketAddr = match addr_str.parse() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let rt = match Runtime::new() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let result = rt.block_on(async {
|
||||
connect_inner(addr, name_str).await
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok((endpoint, connection)) => {
|
||||
let handle = Box::new(MobileHandle {
|
||||
runtime: rt,
|
||||
endpoint,
|
||||
connection: Some(connection),
|
||||
server_addr: addr,
|
||||
server_name: name_str.to_string(),
|
||||
});
|
||||
Box::into_raw(handle)
|
||||
}
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_inner(
|
||||
addr: SocketAddr,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<(Endpoint, quinn::Connection)> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let crypto = build_client_tls_config()?;
|
||||
|
||||
let mut client_config = quinn::ClientConfig::new(Arc::new(
|
||||
quinn::crypto::rustls::QuicClientConfig::try_from(crypto)
|
||||
.map_err(|e| anyhow::anyhow!("QUIC client config: {e}"))?,
|
||||
));
|
||||
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
transport.max_idle_timeout(Some(
|
||||
std::time::Duration::from_secs(120)
|
||||
.try_into()
|
||||
.expect("120s valid"),
|
||||
));
|
||||
client_config.transport_config(Arc::new(transport));
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||
endpoint.set_default_client_config(client_config);
|
||||
|
||||
let connection = endpoint.connect(addr, server_name)?.await?;
|
||||
Ok((endpoint, connection))
|
||||
}
|
||||
|
||||
/// Simulate QUIC connection migration by rebinding the endpoint to a new local address.
|
||||
///
|
||||
/// This is the key proof-of-concept: after rebind, the QUIC connection survives
|
||||
/// and messages continue flowing without loss.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid pointer from `qnpc_connect`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qnpc_migrate(
|
||||
handle: *mut MobileHandle,
|
||||
new_port: u16,
|
||||
) -> QnpcStatus {
|
||||
let handle = match handle.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return QnpcStatus::Error,
|
||||
};
|
||||
|
||||
let new_addr: SocketAddr = format!("0.0.0.0:{new_port}").parse().unwrap();
|
||||
let socket = match std::net::UdpSocket::bind(new_addr) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return QnpcStatus::Error,
|
||||
};
|
||||
|
||||
match handle.endpoint.rebind(socket) {
|
||||
Ok(_) => QnpcStatus::Ok,
|
||||
Err(_) => QnpcStatus::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect and free the handle.
|
||||
///
|
||||
/// # Safety
|
||||
/// `handle` must be a valid pointer from `qnpc_connect`, and must not be used after this call.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qnpc_disconnect(handle: *mut MobileHandle) {
|
||||
if !handle.is_null() {
|
||||
let handle = Box::from_raw(handle);
|
||||
if let Some(conn) = &handle.connection {
|
||||
conn.close(0u32.into(), b"disconnect");
|
||||
}
|
||||
drop(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// ── TLS configuration ───────────────────────────────────────────────────────
|
||||
|
||||
/// Build the rustls `ClientConfig` for the QUIC transport.
|
||||
///
|
||||
/// Without the `insecure-dev` feature, this uses the platform's native root
|
||||
/// certificates for server verification. With `insecure-dev` enabled, all
|
||||
/// certificate verification is skipped (MITM-vulnerable — dev/testing only).
|
||||
fn build_client_tls_config() -> anyhow::Result<rustls::ClientConfig> {
|
||||
#[cfg(feature = "insecure-dev")]
|
||||
{
|
||||
Ok(rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
|
||||
.with_no_client_auth())
|
||||
}
|
||||
#[cfg(not(feature = "insecure-dev"))]
|
||||
{
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
Ok(rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "insecure-dev")]
|
||||
#[derive(Debug)]
|
||||
struct SkipServerVerification;
|
||||
|
||||
#[cfg(feature = "insecure-dev")]
|
||||
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::UdpSocket;
|
||||
|
||||
/// Test-only insecure verifier (always available in test builds).
|
||||
#[derive(Debug)]
|
||||
struct TestSkipServerVerification;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for TestSkipServerVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a test server using the insecure cert verifier.
|
||||
async fn test_connect_inner(
|
||||
addr: SocketAddr,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<(Endpoint, quinn::Connection)> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let crypto = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(TestSkipServerVerification))
|
||||
.with_no_client_auth();
|
||||
|
||||
let mut client_config = quinn::ClientConfig::new(Arc::new(
|
||||
quinn::crypto::rustls::QuicClientConfig::try_from(crypto)
|
||||
.map_err(|e| anyhow::anyhow!("QUIC client config: {e}"))?,
|
||||
));
|
||||
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
transport.max_idle_timeout(Some(
|
||||
std::time::Duration::from_secs(120)
|
||||
.try_into()
|
||||
.expect("120s valid"),
|
||||
));
|
||||
client_config.transport_config(Arc::new(transport));
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||
endpoint.set_default_client_config(client_config);
|
||||
|
||||
let connection = endpoint.connect(addr, server_name)?.await?;
|
||||
Ok((endpoint, connection))
|
||||
}
|
||||
|
||||
/// Prove QUIC connection migration: connect, send messages, rebind the
|
||||
/// UDP socket (simulating wifi→cellular), send more messages, verify
|
||||
/// all messages arrive.
|
||||
#[test]
|
||||
fn quic_connection_migration() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
// Start an in-process echo server.
|
||||
let server_addr = start_echo_server().await;
|
||||
|
||||
// Connect client using test-only insecure verifier.
|
||||
let (endpoint, connection) = test_connect_inner(server_addr, "localhost")
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
// Send 5 messages before migration.
|
||||
for i in 0..5u32 {
|
||||
let (mut send, mut recv) = connection.open_bi().await.unwrap();
|
||||
let msg = format!("pre-migrate-{i}");
|
||||
send.write_all(msg.as_bytes()).await.unwrap();
|
||||
send.finish().unwrap();
|
||||
let response = recv.read_to_end(4096).await.unwrap();
|
||||
assert_eq!(response, msg.as_bytes(), "pre-migrate echo mismatch");
|
||||
}
|
||||
|
||||
// Migrate: rebind to a new local UDP socket (simulates wifi→cellular).
|
||||
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let new_local = new_socket.local_addr().unwrap();
|
||||
endpoint.rebind(new_socket).expect("rebind should succeed");
|
||||
|
||||
// Send 5 more messages after migration.
|
||||
for i in 0..5u32 {
|
||||
let (mut send, mut recv) = connection.open_bi().await.unwrap();
|
||||
let msg = format!("post-migrate-{i}");
|
||||
send.write_all(msg.as_bytes()).await.unwrap();
|
||||
send.finish().unwrap();
|
||||
let response = recv.read_to_end(4096).await.unwrap();
|
||||
assert_eq!(response, msg.as_bytes(), "post-migrate echo mismatch");
|
||||
}
|
||||
|
||||
// Assert: connection still alive after migration.
|
||||
assert!(
|
||||
connection.close_reason().is_none(),
|
||||
"connection should still be open after migration"
|
||||
);
|
||||
|
||||
// Verify the local address changed.
|
||||
let _ = new_local; // We successfully used a new socket.
|
||||
|
||||
connection.close(0u32.into(), b"test done");
|
||||
endpoint.wait_idle().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Start a simple QUIC echo server that echoes back whatever it receives.
|
||||
async fn start_echo_server() -> SocketAddr {
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||
let cert_der = cert.cert.der().to_vec();
|
||||
let key_der = cert.key_pair.serialize_der();
|
||||
|
||||
let cert_chain = vec![rustls::pki_types::CertificateDer::from(cert_der)];
|
||||
let key = rustls::pki_types::PrivateKeyDer::try_from(key_der).unwrap();
|
||||
|
||||
let tls = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(cert_chain, key)
|
||||
.unwrap();
|
||||
|
||||
let server_config = quinn::ServerConfig::with_crypto(Arc::new(
|
||||
quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(),
|
||||
));
|
||||
|
||||
let endpoint = Endpoint::server(
|
||||
server_config,
|
||||
"127.0.0.1:0".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let addr = endpoint.local_addr().unwrap();
|
||||
|
||||
// Spawn echo acceptor.
|
||||
tokio::spawn(async move {
|
||||
while let Some(incoming) = endpoint.accept().await {
|
||||
let connecting = match incoming.accept() {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let conn = match connecting.await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
loop {
|
||||
let (mut send, mut recv) = match conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => break,
|
||||
};
|
||||
let data = match recv.read_to_end(4096).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => break,
|
||||
};
|
||||
let _ = send.write_all(&data).await;
|
||||
let _ = send.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ impl DeliveryService {
|
||||
|
||||
Ok(EnqueueResp {
|
||||
seq: first_seq,
|
||||
delivery_proof: Vec::new(), // TODO: sign in Phase 2
|
||||
delivery_proof: Vec::new(), // Proof generated at RPC handler layer (see v2_handlers/delivery.rs)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user