diff --git a/crates/quicproquo-client/Cargo.toml b/crates/quicproquo-client/Cargo.toml index 355276a..4784f17 100644 --- a/crates/quicproquo-client/Cargo.toml +++ b/crates/quicproquo-client/Cargo.toml @@ -74,6 +74,11 @@ crossterm = { version = "0.28", optional = true } # YAML playbook parsing (only compiled with --features playbook). serde_yaml = { version = "0.9", optional = true } +# v2 SDK-based CLI (thin shell over quicproquo-sdk). +quicproquo-sdk = { path = "../quicproquo-sdk", optional = true } +quicproquo-rpc = { path = "../quicproquo-rpc", optional = true } +rustyline = { workspace = true, optional = true } + [lints] workspace = true @@ -86,6 +91,8 @@ tui = ["dep:ratatui", "dep:crossterm"] # Enable playbook (scripted command execution): YAML parser + serde derives. # Build: cargo build -p quicproquo-client --features playbook playbook = ["dep:serde_yaml"] +# v2 CLI over SDK: cargo build -p quicproquo-client --features v2 +v2 = ["dep:quicproquo-sdk", "dep:quicproquo-rpc", "dep:rustyline"] [dev-dependencies] dashmap = { workspace = true } diff --git a/crates/quicproquo-client/src/client/v2_repl.rs b/crates/quicproquo-client/src/client/v2_repl.rs new file mode 100644 index 0000000..7f569f2 --- /dev/null +++ b/crates/quicproquo-client/src/client/v2_repl.rs @@ -0,0 +1,3 @@ +//! v2 REPL — interactive mode over the SDK. +//! +//! Placeholder module; full implementation will replace the v1 repl. diff --git a/crates/quicproquo-client/src/lib.rs b/crates/quicproquo-client/src/lib.rs index 45d703e..95cf779 100644 --- a/crates/quicproquo-client/src/lib.rs +++ b/crates/quicproquo-client/src/lib.rs @@ -20,6 +20,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use zeroize::Zeroizing; pub mod client; +#[cfg(feature = "v2")] +pub mod v2_commands; pub use client::commands::{ cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify, diff --git a/crates/quicproquo-client/src/main.rs b/crates/quicproquo-client/src/main.rs index 9ee68e7..67d7b2b 100644 --- a/crates/quicproquo-client/src/main.rs +++ b/crates/quicproquo-client/src/main.rs @@ -1,20 +1,35 @@ //! quicproquo CLI client. +// ── v2 feature gate: when compiled with --features v2, use the SDK-based CLI. +#[cfg(feature = "v2")] +mod v2_commands; +#[cfg(feature = "v2")] +mod v2_main; + +#[cfg(feature = "v2")] +fn main() { + v2_main::main(); +} + +// ── v1 CLI (default) ───────────────────────────────────────────────────────── +#[cfg(not(feature = "v2"))] use std::path::{Path, PathBuf}; - +#[cfg(not(feature = "v2"))] use anyhow::Context; +#[cfg(not(feature = "v2"))] use clap::{Parser, Subcommand}; - +#[cfg(not(feature = "v2"))] use quicproquo_client::{ cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify, cmd_fetch_key, cmd_health, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, init_auth, run_repl, set_insecure_skip_verify, ClientAuth, }; -#[cfg(feature = "tui")] +#[cfg(all(feature = "tui", not(feature = "v2")))] use quicproquo_client::client::tui::run_tui; // ── CLI ─────────────────────────────────────────────────────────────────────── +#[cfg(not(feature = "v2"))] #[derive(Debug, Parser)] #[command(name = "qpq", about = "quicproquo CLI client", version)] @@ -90,6 +105,7 @@ struct Args { command: Option, } +#[cfg(not(feature = "v2"))] #[derive(Debug, Subcommand)] enum Command { /// Register a new user via OPAQUE (password never leaves the client). @@ -424,7 +440,7 @@ enum Command { } // ── Helpers ─────────────────────────────────────────────────────────────────── - +#[cfg(not(feature = "v2"))] /// Returns `qpq-{username}.bin` when `state` is still at the default /// (`qpq-state.bin`) and a username has been provided. Otherwise returns /// `state` unchanged. This lets `qpq --username alice` automatically isolate @@ -440,7 +456,7 @@ fn derive_state_path(state: PathBuf, username: Option<&str>) -> PathBuf { // ── Playbook execution ─────────────────────────────────────────────────────── -#[cfg(feature = "playbook")] +#[cfg(all(feature = "playbook", not(feature = "v2")))] async fn run_playbook( playbook_path: &Path, state: &Path, @@ -511,6 +527,7 @@ async fn run_playbook( // ── Entry point ─────────────────────────────────────────────────────────────── +#[cfg(not(feature = "v2"))] #[tokio::main] async fn main() -> anyhow::Result<()> { // Install the rustls crypto provider before any TLS operations. diff --git a/crates/quicproquo-client/src/v2_commands.rs b/crates/quicproquo-client/src/v2_commands.rs new file mode 100644 index 0000000..ae211f5 --- /dev/null +++ b/crates/quicproquo-client/src/v2_commands.rs @@ -0,0 +1,128 @@ +//! v2 CLI command implementations — thin wrappers over the SDK. + +use quicproquo_sdk::client::QpqClient; +use quicproquo_sdk::error::SdkError; + +/// Register a new user account via OPAQUE. +pub async fn cmd_register_user( + client: &mut QpqClient, + username: &str, + password: &str, +) -> Result<(), SdkError> { + client.register(username, password).await?; + let key = client.identity_key().unwrap_or_default(); + println!("registered user: {username}"); + println!("identity key : {}", hex::encode(key)); + Ok(()) +} + +/// Log in via OPAQUE and print session info. +pub async fn cmd_login( + client: &mut QpqClient, + username: &str, + password: &str, +) -> Result<(), SdkError> { + client.login(username, password).await?; + println!("logged in as: {username}"); + if let Some(key) = client.identity_key() { + println!("identity key: {}", hex::encode(key)); + } + Ok(()) +} + +/// Print local identity information. +pub fn cmd_whoami(client: &QpqClient) { + match client.username() { + Some(u) => println!("username : {u}"), + None => println!("username : (not logged in)"), + } + match client.identity_key() { + Some(k) => println!("identity key: {}", hex::encode(k)), + None => println!("identity key: (none)"), + } + println!("connected : {}", client.is_connected()); + println!("authenticated: {}", client.is_authenticated()); +} + +/// Health check — connect to the server and report status. +pub async fn cmd_health(client: &mut QpqClient) -> Result<(), SdkError> { + let start = std::time::Instant::now(); + // The SDK connect() already establishes a QUIC connection. + // If we're already connected, just report success. + if !client.is_connected() { + client.connect().await?; + } + let rtt_ms = start.elapsed().as_millis(); + println!("status : ok"); + println!("rtt : {rtt_ms}ms"); + Ok(()) +} + +/// Resolve a username to its identity key. +pub async fn cmd_resolve(client: &mut QpqClient, username: &str) -> Result<(), SdkError> { + let rpc = client.rpc()?; + match quicproquo_sdk::users::resolve_user(rpc, username).await? { + Some(key) => { + println!("{username} -> {}", hex::encode(&key)); + } + None => { + println!("{username}: not found"); + } + } + Ok(()) +} + +/// List registered devices. +pub async fn cmd_devices_list(client: &mut QpqClient) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let devices = quicproquo_sdk::devices::list_devices(rpc).await?; + if devices.is_empty() { + println!("no devices registered"); + } else { + println!("{:<36} {:<20} {}", "DEVICE ID", "NAME", "REGISTERED AT"); + for d in &devices { + println!( + "{:<36} {:<20} {}", + hex::encode(&d.device_id), + d.device_name, + d.registered_at, + ); + } + } + Ok(()) +} + +/// Register a new device. +pub async fn cmd_devices_register( + client: &mut QpqClient, + device_id: &str, + device_name: &str, +) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let id_bytes = hex::decode(device_id) + .map_err(|e| SdkError::Other(anyhow::anyhow!("invalid device_id hex: {e}")))?; + let was_new = quicproquo_sdk::devices::register_device(rpc, &id_bytes, device_name).await?; + if was_new { + println!("device registered: {device_name}"); + } else { + println!("device already registered: {device_name}"); + } + Ok(()) +} + +/// Revoke a device. +pub async fn cmd_devices_revoke( + client: &mut QpqClient, + device_id: &str, +) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let id_bytes = hex::decode(device_id) + .map_err(|e| SdkError::Other(anyhow::anyhow!("invalid device_id hex: {e}")))?; + let revoked = quicproquo_sdk::devices::revoke_device(rpc, &id_bytes).await?; + if revoked { + println!("device revoked: {device_id}"); + } else { + println!("device not found: {device_id}"); + } + Ok(()) +} diff --git a/crates/quicproquo-client/src/v2_main.rs b/crates/quicproquo-client/src/v2_main.rs new file mode 100644 index 0000000..2ed86a0 --- /dev/null +++ b/crates/quicproquo-client/src/v2_main.rs @@ -0,0 +1,484 @@ +//! v2 CLI entry point — thin shell over `quicproquo_sdk::QpqClient`. +//! +//! Activated via `--features v2`. Replaces the v1 Cap'n Proto RPC main +//! with a simplified command surface backed by the SDK. + +use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; +use std::time::Duration; + +use anyhow::Context; +use clap::{Parser, Subcommand}; + +use quicproquo_sdk::client::QpqClient; +use quicproquo_sdk::config::ClientConfig; + +use crate::v2_commands; + +// ── CLI ─────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Parser)] +#[command(name = "qpq", about = "quicproquo CLI client (v2)", version)] +struct Args { + /// Server address (host:port). + #[arg(long, global = true, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] + server: String, + + /// TLS server name (must match certificate SAN). + #[arg(long, global = true, default_value = "localhost", env = "QPQ_SERVER_NAME")] + server_name: String, + + /// Path to local conversation database. + #[arg(long, global = true, default_value = "conversations.db", env = "QPQ_CONV_DB")] + db_path: PathBuf, + + /// Password for encrypting the local database. + #[arg(long, global = true, env = "QPQ_DB_PASSWORD")] + db_password: Option, + + /// Path to the client state file (identity key, MLS state). + #[arg(long, global = true, default_value = "qpq-state.bin", env = "QPQ_STATE")] + state: PathBuf, + + /// DANGER: Skip TLS certificate verification. Development only. + #[arg( + long = "danger-accept-invalid-certs", + global = true, + env = "QPQ_DANGER_ACCEPT_INVALID_CERTS" + )] + danger_accept_invalid_certs: bool, + + /// Do not auto-start a local qpq-server. + #[arg(long, global = true, env = "QPQ_NO_SERVER")] + no_server: bool, + + #[command(subcommand)] + command: Cmd, +} + +#[derive(Debug, Subcommand)] +enum Cmd { + /// Register a new user via OPAQUE (password never leaves the client). + RegisterUser { + /// Username for the new account. + #[arg(long)] + username: String, + /// Password (used in OPAQUE PAKE; server never sees it). + #[arg(long)] + password: String, + }, + + /// Log in via OPAQUE and receive a session token. + Login { + #[arg(long)] + username: String, + #[arg(long)] + password: String, + }, + + /// Show local identity info. + Whoami, + + /// Server health check. + Health, + + /// Send a message to a conversation. + Send { + /// Conversation name (group name or DM peer username). + #[arg(long)] + to: String, + /// Message text. + #[arg(long)] + msg: String, + }, + + /// Receive pending messages from a conversation. + Recv { + /// Conversation name. + #[arg(long)] + from: String, + }, + + /// Start or resume a DM with a user. + Dm { + /// Peer username. + username: String, + }, + + /// Group management commands. + Group { + #[command(subcommand)] + action: GroupCmd, + }, + + /// Resolve a username to its identity key. + Resolve { + /// Username to look up. + username: String, + }, + + /// Device management. + Devices { + #[command(subcommand)] + action: DevicesCmd, + }, +} + +#[derive(Debug, Subcommand)] +enum GroupCmd { + /// Create a new group. + Create { + /// Group name. + name: String, + }, + /// Invite a user to a group. + Invite { + /// Group name. + #[arg(long)] + group: String, + /// Username to invite. + #[arg(long)] + user: String, + }, +} + +#[derive(Debug, Subcommand)] +enum DevicesCmd { + /// List registered devices. + List, + /// Register a new device. + Register { + /// Device ID (hex). + #[arg(long)] + id: String, + /// Human-readable device name. + #[arg(long)] + name: String, + }, + /// Revoke a device. + Revoke { + /// Device ID (hex). + #[arg(long)] + id: String, + }, +} + +// ── Auto-server launch ─────────────────────────────────────────────────────── + +/// RAII guard that kills an auto-started server process on drop. +struct ServerGuard(Option); + +impl Drop for ServerGuard { + fn drop(&mut self) { + if let Some(ref mut child) = self.0 { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// Find the `qpq-server` binary: same directory as current exe, then PATH. +fn find_server_binary() -> Option { + if let Ok(exe) = std::env::current_exe() { + let sibling = exe.with_file_name("qpq-server"); + if sibling.exists() { + return Some(sibling); + } + } + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths) + .map(|dir| dir.join("qpq-server")) + .find(|p| p.exists()) + }) +} + +/// Try a QUIC health probe to the server address. +async fn probe_server(server_addr: &str) -> bool { + use std::net::ToSocketAddrs; + let addr = match server_addr.to_socket_addrs() { + Ok(mut addrs) => match addrs.next() { + Some(a) => a, + None => return false, + }, + Err(_) => return false, + }; + // Simple TCP probe — if the port is open, the server is likely running. + tokio::net::TcpStream::connect(addr) + .await + .is_ok() +} + +/// Start a local qpq-server if one isn't already running. +/// Returns a guard that kills the child on drop (if we started one). +async fn ensure_server_running( + server_addr: &str, + data_dir: &Path, + no_server: bool, +) -> anyhow::Result { + if no_server { + return Ok(ServerGuard(None)); + } + + if probe_server(server_addr).await { + return Ok(ServerGuard(None)); + } + + let binary = find_server_binary().ok_or_else(|| { + anyhow::anyhow!( + "server at {server_addr} is not reachable and qpq-server binary not found; \ + start a server manually or install qpq-server" + ) + })?; + + let cert_path = data_dir.join("server-cert.der"); + let key_path = data_dir.join("server-key.der"); + + eprintln!("starting server on {server_addr}..."); + + let child = ProcessCommand::new(&binary) + .args([ + "--allow-insecure-auth", + "--listen", + server_addr, + "--tls-cert", + &cert_path.to_string_lossy(), + "--tls-key", + &key_path.to_string_lossy(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .with_context(|| format!("failed to spawn {}", binary.display()))?; + + let guard = ServerGuard(Some(child)); + + // Poll until the server is ready. + let mut delay = Duration::from_millis(100); + let max_wait = Duration::from_secs(3); + let start = std::time::Instant::now(); + + loop { + tokio::time::sleep(delay).await; + + if probe_server(server_addr).await { + eprintln!("server ready"); + return Ok(guard); + } + + if start.elapsed() > max_wait { + anyhow::bail!( + "auto-started qpq-server but it did not become ready within {max_wait:?}" + ); + } + + delay = (delay * 2).min(Duration::from_secs(1)); + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Build a `ClientConfig` from CLI args. +fn build_config(args: &Args) -> anyhow::Result { + let server_addr = args + .server + .parse() + .with_context(|| format!("invalid server address: {}", args.server))?; + + Ok(ClientConfig { + server_addr, + server_name: args.server_name.clone(), + db_path: args.db_path.clone(), + db_password: args.db_password.clone(), + state_path: args.state.clone(), + accept_invalid_certs: args.danger_accept_invalid_certs, + ..ClientConfig::default() + }) +} + +/// Build, connect, and return a `QpqClient`. Loads identity from state file +/// if it exists. +async fn connect_client(args: &Args) -> anyhow::Result { + let config = build_config(args)?; + let mut client = QpqClient::new(config); + + // Try loading identity from state file. + if args.state.exists() { + match quicproquo_sdk::state::load_state(&args.state, args.db_password.as_deref()) { + Ok(stored) => { + let keypair = quicproquo_core::IdentityKeypair::from_seed(stored.identity_seed); + client.set_identity_key(keypair.public_key_bytes().to_vec()); + } + Err(e) => { + tracing::debug!("could not load state from {}: {e}", args.state.display()); + } + } + } + + client.connect().await.context("failed to connect to server")?; + Ok(client) +} + +// ── Entry point ────────────────────────────────────────────────────────────── + +pub fn main() { + // Install the rustls crypto provider before any TLS operations. + let _ = rustls::crypto::ring::default_provider().install_default(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .init(); + + let args = Args::parse(); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to create tokio runtime"); + + if let Err(e) = rt.block_on(run(args)) { + eprintln!("error: {e:#}"); + std::process::exit(1); + } +} + +async fn run(args: Args) -> anyhow::Result<()> { + // Auto-start server if needed (except for whoami which is local-only). + let data_dir = args.state.parent().unwrap_or_else(|| Path::new(".")); + let _server_guard = match args.command { + Cmd::Whoami => ServerGuard(None), + _ => ensure_server_running(&args.server, data_dir, args.no_server).await?, + }; + + match args.command { + Cmd::RegisterUser { + ref username, + ref password, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_register_user(&mut client, username, password) + .await + .context("register-user failed")?; + } + + Cmd::Login { + ref username, + ref password, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_login(&mut client, username, password) + .await + .context("login failed")?; + } + + Cmd::Whoami => { + // Whoami is local-only — create client without connecting. + let config = build_config(&args)?; + let mut client = QpqClient::new(config); + if args.state.exists() { + match quicproquo_sdk::state::load_state( + &args.state, + args.db_password.as_deref(), + ) { + Ok(stored) => { + let keypair = + quicproquo_core::IdentityKeypair::from_seed(stored.identity_seed); + client.set_identity_key(keypair.public_key_bytes().to_vec()); + } + Err(e) => { + eprintln!("warning: could not load state: {e}"); + } + } + } + v2_commands::cmd_whoami(&client); + } + + Cmd::Health => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_health(&mut client) + .await + .context("health check failed")?; + } + + Cmd::Resolve { ref username } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_resolve(&mut client, username) + .await + .context("resolve failed")?; + } + + Cmd::Dm { ref username } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_resolve(&mut client, username) + .await + .context("dm setup failed")?; + // For now, print the resolved key. Full DM creation requires + // MLS group state, which will be handled in the REPL flow. + println!("(DM creation with full MLS setup is available in the REPL)"); + } + + Cmd::Send { ref to, ref msg } => { + let _ = (to, msg); + let _client = connect_client(&args).await?; + // Full send requires MLS group state restoration — deferred to REPL. + println!("(send is currently available in the REPL; one-shot send coming soon)"); + } + + Cmd::Recv { ref from } => { + let _ = from; + let _client = connect_client(&args).await?; + println!("(recv is currently available in the REPL; one-shot recv coming soon)"); + } + + Cmd::Group { + action: GroupCmd::Create { ref name }, + } => { + let _ = name; + let _client = connect_client(&args).await?; + println!("(group create is currently available in the REPL; one-shot coming soon)"); + } + + Cmd::Group { + action: + GroupCmd::Invite { + ref group, + ref user, + }, + } => { + let _ = (group, user); + let _client = connect_client(&args).await?; + println!("(group invite is currently available in the REPL; one-shot coming soon)"); + } + + Cmd::Devices { + action: DevicesCmd::List, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_devices_list(&mut client) + .await + .context("devices list failed")?; + } + + Cmd::Devices { + action: DevicesCmd::Register { ref id, ref name }, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_devices_register(&mut client, id, name) + .await + .context("device register failed")?; + } + + Cmd::Devices { + action: DevicesCmd::Revoke { ref id }, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_devices_revoke(&mut client, id) + .await + .context("device revoke failed")?; + } + } + + Ok(()) +}