- Expand crate-level docs for quicprochat-rpc (architecture, wire format, module map) and quicprochat-sdk (connection lifecycle, event subscription, module descriptions). - Add /// doc comments to all undocumented pub fn/struct/enum items in server domain services (keys, channels, devices, users, account, p2p, blobs) and domain types. - Fix rustdoc broken intra-doc links in plugin-api (HookResult, qpc_plugin_init), federation/mod.rs (Store), and client main.rs (unescaped brackets).
1038 lines
34 KiB
Rust
1038 lines
34 KiB
Rust
//! quicprochat 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 quicprochat_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(all(feature = "tui", not(feature = "v2")))]
|
|
use quicprochat_client::client::tui::run_tui;
|
|
|
|
// ── Config file loading ──────────────────────────────────────────────────────
|
|
//
|
|
// Loads a TOML config file and sets QPQ_* environment variables for values
|
|
// not already set. This runs BEFORE clap parses, so the natural precedence is:
|
|
// CLI flags > environment variables > config file > compiled defaults.
|
|
//
|
|
// Config file search order:
|
|
// 1. --config <path> (parsed manually from argv)
|
|
// 2. $QPC_CONFIG env var
|
|
// 3. $XDG_CONFIG_HOME/qpc/config.toml (usually ~/.config/qpc/config.toml)
|
|
// 4. ~/.qpc.toml
|
|
#[cfg(not(feature = "v2"))]
|
|
mod client_config {
|
|
use serde::Deserialize;
|
|
use std::path::PathBuf;
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub struct ClientFileConfig {
|
|
pub server: Option<String>,
|
|
pub server_name: Option<String>,
|
|
pub ca_cert: Option<String>,
|
|
pub username: Option<String>,
|
|
pub password: Option<String>,
|
|
pub access_token: Option<String>,
|
|
pub device_id: Option<String>,
|
|
pub state_password: Option<String>,
|
|
pub state: Option<String>,
|
|
pub danger_accept_invalid_certs: Option<bool>,
|
|
pub no_server: Option<bool>,
|
|
}
|
|
|
|
/// Find and load the config file. Returns the parsed config (or default if
|
|
/// no file is found).
|
|
pub fn load_client_config() -> ClientFileConfig {
|
|
let path = find_config_path();
|
|
let path = match path {
|
|
Some(p) if p.exists() => p,
|
|
_ => return ClientFileConfig::default(),
|
|
};
|
|
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(contents) => match toml::from_str(&contents) {
|
|
Ok(cfg) => {
|
|
eprintln!("Loaded config: {}", path.display());
|
|
cfg
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Warning: failed to parse {}: {e}", path.display());
|
|
ClientFileConfig::default()
|
|
}
|
|
},
|
|
Err(e) => {
|
|
eprintln!("Warning: failed to read {}: {e}", path.display());
|
|
ClientFileConfig::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn find_config_path() -> Option<PathBuf> {
|
|
// 1. --config <path> from argv (before clap parses).
|
|
let args: Vec<String> = std::env::args().collect();
|
|
for i in 0..args.len().saturating_sub(1) {
|
|
if args[i] == "--config" || args[i] == "-c" {
|
|
return Some(PathBuf::from(&args[i + 1]));
|
|
}
|
|
}
|
|
|
|
// 2. $QPC_CONFIG env var.
|
|
if let Ok(p) = std::env::var("QPC_CONFIG") {
|
|
return Some(PathBuf::from(p));
|
|
}
|
|
|
|
// 3. $XDG_CONFIG_HOME/qpc/config.toml
|
|
let xdg = std::env::var("XDG_CONFIG_HOME")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| {
|
|
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
|
PathBuf::from(home).join(".config")
|
|
});
|
|
let xdg_path = xdg.join("qpc").join("config.toml");
|
|
if xdg_path.exists() {
|
|
return Some(xdg_path);
|
|
}
|
|
|
|
// 4. ~/.qpc.toml
|
|
if let Ok(home) = std::env::var("HOME") {
|
|
let home_path = PathBuf::from(home).join(".qpc.toml");
|
|
if home_path.exists() {
|
|
return Some(home_path);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Set QPQ_* env vars from config values, but only if they're not already set.
|
|
pub fn apply_config_to_env(cfg: &ClientFileConfig) {
|
|
fn set_if_empty(key: &str, val: &str) {
|
|
if std::env::var(key).is_err() {
|
|
std::env::set_var(key, val);
|
|
}
|
|
}
|
|
|
|
if let Some(ref v) = cfg.server {
|
|
set_if_empty("QPQ_SERVER", v);
|
|
}
|
|
if let Some(ref v) = cfg.server_name {
|
|
set_if_empty("QPQ_SERVER_NAME", v);
|
|
}
|
|
if let Some(ref v) = cfg.ca_cert {
|
|
set_if_empty("QPQ_CA_CERT", v);
|
|
}
|
|
if let Some(ref v) = cfg.username {
|
|
set_if_empty("QPQ_USERNAME", v);
|
|
}
|
|
if let Some(ref v) = cfg.password {
|
|
set_if_empty("QPQ_PASSWORD", v);
|
|
}
|
|
if let Some(ref v) = cfg.access_token {
|
|
set_if_empty("QPQ_ACCESS_TOKEN", v);
|
|
}
|
|
if let Some(ref v) = cfg.device_id {
|
|
set_if_empty("QPQ_DEVICE_ID", v);
|
|
}
|
|
if let Some(ref v) = cfg.state_password {
|
|
set_if_empty("QPQ_STATE_PASSWORD", v);
|
|
}
|
|
if let Some(ref v) = cfg.state {
|
|
set_if_empty("QPQ_STATE", v);
|
|
}
|
|
if let Some(v) = cfg.danger_accept_invalid_certs {
|
|
if v {
|
|
set_if_empty("QPQ_DANGER_ACCEPT_INVALID_CERTS", "true");
|
|
}
|
|
}
|
|
if let Some(v) = cfg.no_server {
|
|
if v {
|
|
set_if_empty("QPQ_NO_SERVER", "true");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
#[cfg(not(feature = "v2"))]
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(name = "qpc", about = "quicprochat CLI client", version)]
|
|
struct Args {
|
|
/// Path to a TOML config file (auto-detected from ~/.config/qpc/config.toml or ~/.qpc.toml).
|
|
#[arg(long, short = 'c', global = true, env = "QPC_CONFIG")]
|
|
config: Option<PathBuf>,
|
|
|
|
/// Path to the server's TLS certificate (self-signed by default).
|
|
#[arg(
|
|
long,
|
|
global = true,
|
|
default_value = "data/server-cert.der",
|
|
env = "QPQ_CA_CERT"
|
|
)]
|
|
ca_cert: PathBuf,
|
|
|
|
/// Expected TLS server name (must match the certificate SAN).
|
|
#[arg(
|
|
long,
|
|
global = true,
|
|
default_value = "localhost",
|
|
env = "QPQ_SERVER_NAME"
|
|
)]
|
|
server_name: String,
|
|
|
|
/// Bearer token or OPAQUE session token for authenticated requests.
|
|
/// Not required for register-user and login commands.
|
|
#[arg(
|
|
long,
|
|
global = true,
|
|
env = "QPQ_ACCESS_TOKEN",
|
|
default_value = ""
|
|
)]
|
|
access_token: String,
|
|
|
|
/// Optional device identifier (UUID bytes encoded as hex or raw string).
|
|
#[arg(long, global = true, env = "QPQ_DEVICE_ID")]
|
|
device_id: Option<String>,
|
|
|
|
/// Password to encrypt/decrypt client state files (QPCE format).
|
|
/// If set, state files are encrypted at rest with Argon2id + ChaCha20Poly1305.
|
|
#[arg(long, global = true, env = "QPQ_STATE_PASSWORD")]
|
|
state_password: Option<String>,
|
|
|
|
/// DANGER: Skip TLS certificate verification. Development only.
|
|
/// Disables all certificate checks, making the connection vulnerable to MITM attacks.
|
|
#[arg(
|
|
long = "danger-accept-invalid-certs",
|
|
global = true,
|
|
env = "QPQ_DANGER_ACCEPT_INVALID_CERTS"
|
|
)]
|
|
danger_accept_invalid_certs: bool,
|
|
|
|
// ── Default-repl args (used when no subcommand is given) ─────────
|
|
/// State file path (identity + MLS state). Used when running the default REPL.
|
|
#[arg(long, default_value = "qpc-state.bin", env = "QPQ_STATE")]
|
|
state: PathBuf,
|
|
|
|
/// Server address (host:port). Used when running the default REPL.
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
|
|
/// OPAQUE username for automatic registration/login.
|
|
#[arg(long, env = "QPQ_USERNAME")]
|
|
username: Option<String>,
|
|
|
|
/// OPAQUE password (prompted securely if --username is set but --password is not).
|
|
#[arg(long, env = "QPQ_PASSWORD")]
|
|
password: Option<String>,
|
|
|
|
/// Do not auto-start a local qpc-server (useful when connecting to a remote server).
|
|
#[arg(long, env = "QPQ_NO_SERVER")]
|
|
no_server: bool,
|
|
|
|
#[command(subcommand)]
|
|
command: Option<Command>,
|
|
}
|
|
|
|
#[cfg(not(feature = "v2"))]
|
|
#[derive(Debug, Subcommand)]
|
|
enum Command {
|
|
/// Register a new user via OPAQUE (password never leaves the client).
|
|
RegisterUser {
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
/// Username for the new account.
|
|
#[arg(long)]
|
|
username: String,
|
|
/// Password (will be used in OPAQUE PAKE; server never sees it).
|
|
#[arg(long)]
|
|
password: String,
|
|
},
|
|
|
|
/// Log in via OPAQUE and receive a session token.
|
|
Login {
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
#[arg(long)]
|
|
username: String,
|
|
#[arg(long)]
|
|
password: String,
|
|
/// Hex-encoded Ed25519 identity key (64 hex chars). Optional if --state is provided.
|
|
#[arg(long)]
|
|
identity_key: Option<String>,
|
|
/// State file to derive the identity key (requires same password if encrypted).
|
|
#[arg(long)]
|
|
state: Option<PathBuf>,
|
|
/// Password for the encrypted state file (if any).
|
|
#[arg(long)]
|
|
state_password: Option<String>,
|
|
},
|
|
|
|
/// Show local identity key, fingerprint, group status, and hybrid key status.
|
|
Whoami {
|
|
/// State file path (identity + MLS state).
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
},
|
|
|
|
/// Check server connectivity and print status.
|
|
Health {
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
},
|
|
|
|
/// Check if a peer has registered a hybrid key (non-consuming lookup).
|
|
CheckKey {
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
|
|
/// Peer's Ed25519 identity public key (64 hex chars = 32 bytes).
|
|
identity_key: String,
|
|
},
|
|
|
|
/// Send a Ping to the server and print the round-trip time.
|
|
Ping {
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
},
|
|
|
|
/// Generate a fresh MLS KeyPackage and upload it to the Authentication Service.
|
|
Register {
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
},
|
|
|
|
/// Fetch a peer's KeyPackage from the Authentication Service.
|
|
FetchKey {
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
|
|
/// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes).
|
|
identity_key: String,
|
|
},
|
|
|
|
/// Run a two-party MLS demo (creator + joiner) against live AS and DS.
|
|
DemoGroup {
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
},
|
|
|
|
/// Upload the persistent identity's KeyPackage to the AS (uses state file).
|
|
RegisterState {
|
|
/// State file path (identity + MLS state).
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
|
|
/// Authentication Service address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
},
|
|
|
|
/// Refresh the KeyPackage on the server (existing state only).
|
|
/// Run periodically (e.g. before server TTL ~24h) or after your KeyPackage was consumed so others can invite you.
|
|
RefreshKeypackage {
|
|
/// State file path (identity + MLS state).
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
},
|
|
|
|
/// Create a persistent group and save state to disk.
|
|
CreateGroup {
|
|
/// State file path (identity + MLS state).
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
|
|
/// Group identifier (arbitrary bytes, typically a human-readable name).
|
|
#[arg(long)]
|
|
group_id: String,
|
|
},
|
|
|
|
/// Invite a peer into the group and deliver a Welcome via DS.
|
|
Invite {
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
/// Peer identity public key (64 hex chars = 32 bytes).
|
|
#[arg(long)]
|
|
peer_key: String,
|
|
},
|
|
|
|
/// Join a group by fetching the Welcome from the DS.
|
|
Join {
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
},
|
|
|
|
/// Send an application message via the DS.
|
|
Send {
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
/// Recipient identity key (hex, 32 bytes -> 64 chars). Omit when using --all.
|
|
#[arg(long)]
|
|
peer_key: Option<String>,
|
|
/// Send to all other group members (N-way groups).
|
|
#[arg(long)]
|
|
all: bool,
|
|
/// Plaintext message to send.
|
|
#[arg(long)]
|
|
msg: String,
|
|
},
|
|
|
|
/// Receive and decrypt all pending messages from the DS.
|
|
Recv {
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
|
|
/// Wait for up to this many milliseconds if no messages are queued.
|
|
#[arg(long, default_value_t = 0)]
|
|
wait_ms: u64,
|
|
|
|
/// Continuously long-poll for messages.
|
|
#[arg(long)]
|
|
stream: bool,
|
|
},
|
|
|
|
/// Interactive multi-conversation REPL. Supports /dm, /create-group, /invite, /join, /switch, and more.
|
|
/// Automatically registers and logs in if --username/--password are provided (or prompts interactively).
|
|
Repl {
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
/// OPAQUE username for automatic registration/login.
|
|
#[arg(long, env = "QPQ_USERNAME")]
|
|
username: Option<String>,
|
|
/// OPAQUE password (prompted securely if --username is set but --password is not).
|
|
#[arg(long, env = "QPQ_PASSWORD")]
|
|
password: Option<String>,
|
|
/// Do not auto-start a local qpc-server.
|
|
#[arg(long, env = "QPQ_NO_SERVER")]
|
|
no_server: bool,
|
|
},
|
|
|
|
/// Full-screen Ratatui TUI (requires --features tui).
|
|
/// Channels sidebar, scrollable message view, and inline input bar.
|
|
#[cfg(feature = "tui")]
|
|
Tui {
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
/// OPAQUE username for automatic registration/login.
|
|
#[arg(long, env = "QPQ_USERNAME")]
|
|
username: Option<String>,
|
|
/// OPAQUE password (prompted securely if --username is set but --password is not).
|
|
#[arg(long, env = "QPQ_PASSWORD")]
|
|
password: Option<String>,
|
|
},
|
|
|
|
/// Interactive 1:1 chat: type to send, incoming messages printed as \[peer\] msg. Ctrl+D to exit.
|
|
/// In a two-person group, peer is chosen automatically; use --peer-key only with 3+ members.
|
|
Chat {
|
|
#[arg(
|
|
long,
|
|
default_value = "qpc-state.bin",
|
|
env = "QPQ_STATE"
|
|
)]
|
|
state: PathBuf,
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
/// Peer identity key (hex, 64 chars). Omit in a two-person group to use the only other member.
|
|
#[arg(long)]
|
|
peer_key: Option<String>,
|
|
/// How often to poll for incoming messages (milliseconds).
|
|
#[arg(long, default_value_t = 500)]
|
|
poll_interval_ms: u64,
|
|
},
|
|
|
|
/// Export a conversation's message history to an encrypted, tamper-evident transcript file.
|
|
///
|
|
/// The output file uses Argon2id + ChaCha20-Poly1305 encryption with a SHA-256 hash chain
|
|
/// linking every record. Use `qpc export verify` to check chain integrity without decrypting.
|
|
Export {
|
|
/// Path to the conversation database (.convdb file).
|
|
#[arg(long, default_value = "qpc-convdb.sqlite", env = "QPQ_CONV_DB")]
|
|
conv_db: PathBuf,
|
|
|
|
/// Conversation ID to export (32 hex chars = 16 bytes).
|
|
#[arg(long)]
|
|
conv_id: String,
|
|
|
|
/// Output path for the .qpct transcript file.
|
|
#[arg(long, default_value = "transcript.qpct")]
|
|
output: PathBuf,
|
|
|
|
/// Password used to encrypt the transcript (separate from the state/DB password).
|
|
#[arg(long, env = "QPQ_TRANSCRIPT_PASSWORD")]
|
|
transcript_password: Option<String>,
|
|
|
|
/// Password for the encrypted conversation database (if any).
|
|
#[arg(long, env = "QPQ_STATE_PASSWORD")]
|
|
db_password: Option<String>,
|
|
},
|
|
|
|
/// Verify the hash-chain integrity of a transcript file without decrypting content.
|
|
ExportVerify {
|
|
/// Path to the .qpct transcript file to verify.
|
|
#[arg(long)]
|
|
input: PathBuf,
|
|
},
|
|
|
|
/// Execute a YAML playbook (scripted command sequence) and exit.
|
|
/// Requires `--features playbook`.
|
|
#[cfg(feature = "playbook")]
|
|
Run {
|
|
/// Path to the YAML playbook file.
|
|
playbook: PathBuf,
|
|
|
|
/// State file path (identity + MLS state).
|
|
#[arg(long, default_value = "qpc-state.bin", env = "QPQ_STATE")]
|
|
state: PathBuf,
|
|
|
|
/// Server address (host:port).
|
|
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
|
|
server: String,
|
|
|
|
/// OPAQUE username for automatic login.
|
|
#[arg(long, env = "QPQ_USERNAME")]
|
|
username: Option<String>,
|
|
|
|
/// OPAQUE password.
|
|
#[arg(long, env = "QPQ_PASSWORD")]
|
|
password: Option<String>,
|
|
|
|
/// Override playbook variables: KEY=VALUE (repeatable).
|
|
#[arg(long = "var", short = 'V')]
|
|
vars: Vec<String>,
|
|
},
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
#[cfg(not(feature = "v2"))]
|
|
/// Returns `qpc-{username}.bin` when `state` is still at the default
|
|
/// (`qpc-state.bin`) and a username has been provided. Otherwise returns
|
|
/// `state` unchanged. This lets `qpc --username alice` automatically isolate
|
|
/// Alice's state without requiring a manual `--state` flag.
|
|
fn derive_state_path(state: PathBuf, username: Option<&str>) -> PathBuf {
|
|
if state == Path::new("qpc-state.bin") {
|
|
if let Some(uname) = username {
|
|
return PathBuf::from(format!("qpc-{uname}.bin"));
|
|
}
|
|
}
|
|
state
|
|
}
|
|
|
|
// ── Playbook execution ───────────────────────────────────────────────────────
|
|
|
|
#[cfg(all(feature = "playbook", not(feature = "v2")))]
|
|
async fn run_playbook(
|
|
playbook_path: &Path,
|
|
state: &Path,
|
|
server: &str,
|
|
ca_cert: &Path,
|
|
server_name: &str,
|
|
state_pw: Option<&str>,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
access_token: &str,
|
|
device_id: Option<&str>,
|
|
extra_vars: &[String],
|
|
) -> anyhow::Result<()> {
|
|
use quicprochat_client::PlaybookRunner;
|
|
|
|
let insecure = std::env::var("QPQ_DANGER_ACCEPT_INVALID_CERTS").is_ok();
|
|
|
|
// Connect to server.
|
|
let client =
|
|
quicprochat_client::connect_node_opt(server, ca_cert, server_name, insecure)
|
|
.await
|
|
.context("connect to server")?;
|
|
|
|
// Build session state.
|
|
let mut session = quicprochat_client::client::session::SessionState::load(state, state_pw)
|
|
.context("load session state")?;
|
|
|
|
// If username/password provided, do OPAQUE login.
|
|
if let (Some(uname), Some(pw)) = (username, password) {
|
|
if let Err(e) =
|
|
quicprochat_client::opaque_login(&client, uname, pw, &session.identity.public_key_bytes()).await
|
|
{
|
|
eprintln!("OPAQUE login failed: {e:#}");
|
|
}
|
|
} else if !access_token.is_empty() {
|
|
let auth = ClientAuth::from_parts(access_token.to_string(), device_id.map(String::from));
|
|
init_auth(auth);
|
|
}
|
|
|
|
// Load playbook.
|
|
let mut runner = PlaybookRunner::from_file(playbook_path)
|
|
.with_context(|| format!("load playbook: {}", playbook_path.display()))?;
|
|
|
|
// Inject extra variables from --var KEY=VALUE flags.
|
|
for kv in extra_vars {
|
|
if let Some((k, v)) = kv.split_once('=') {
|
|
runner.set_var(k, v);
|
|
} else {
|
|
eprintln!("warning: ignoring malformed --var '{kv}' (expected KEY=VALUE)");
|
|
}
|
|
}
|
|
|
|
// Inject connection info as variables.
|
|
runner.set_var("_server", server);
|
|
if let Some(u) = username {
|
|
runner.set_var("_username", u);
|
|
}
|
|
|
|
let report = runner.run(&mut session, &client).await;
|
|
print!("{report}");
|
|
|
|
if report.all_passed() {
|
|
Ok(())
|
|
} else {
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(not(feature = "v2"))]
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
// 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();
|
|
|
|
// Load config file and apply to env BEFORE clap parses (so config values
|
|
// act as defaults that env vars and CLI flags can override).
|
|
{
|
|
let cfg = client_config::load_client_config();
|
|
client_config::apply_config_to_env(&cfg);
|
|
}
|
|
|
|
let args = Args::parse();
|
|
|
|
if args.danger_accept_invalid_certs {
|
|
eprintln!("WARNING: TLS verification disabled — insecure mode");
|
|
set_insecure_skip_verify(true);
|
|
}
|
|
|
|
// For the REPL and TUI, defer init_auth so they can resolve their own token via OPAQUE.
|
|
// For all other subcommands, initialize auth immediately.
|
|
#[cfg(not(feature = "tui"))]
|
|
let is_repl = matches!(args.command, None | Some(Command::Repl { .. }));
|
|
#[cfg(feature = "tui")]
|
|
let is_repl = matches!(args.command, None | Some(Command::Repl { .. }) | Some(Command::Tui { .. }));
|
|
if !is_repl {
|
|
let auth_ctx = ClientAuth::from_parts(args.access_token.clone(), args.device_id.clone());
|
|
init_auth(auth_ctx);
|
|
}
|
|
|
|
let state_pw = args.state_password.as_deref();
|
|
|
|
// Default to REPL when no subcommand is given.
|
|
let no_server = args.no_server;
|
|
let command = args.command.unwrap_or_else(|| Command::Repl {
|
|
state: derive_state_path(args.state, args.username.as_deref()),
|
|
server: args.server,
|
|
username: args.username,
|
|
password: args.password,
|
|
no_server,
|
|
});
|
|
|
|
match command {
|
|
Command::RegisterUser {
|
|
server,
|
|
username,
|
|
password,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_register_user(
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
&username,
|
|
&password,
|
|
None,
|
|
))
|
|
.await
|
|
}
|
|
Command::Login {
|
|
server,
|
|
username,
|
|
password,
|
|
identity_key,
|
|
state,
|
|
state_password,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_login(
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
&username,
|
|
&password,
|
|
identity_key.as_deref(),
|
|
state.as_deref(),
|
|
state_password.as_deref(),
|
|
))
|
|
.await
|
|
}
|
|
Command::Whoami { state } => cmd_whoami(&state, state_pw),
|
|
Command::Health { server } => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_health(&server, &args.ca_cert, &args.server_name))
|
|
.await
|
|
}
|
|
Command::CheckKey {
|
|
server,
|
|
identity_key,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_check_key(
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
&identity_key,
|
|
))
|
|
.await
|
|
}
|
|
Command::Ping { server } => cmd_ping(&server, &args.ca_cert, &args.server_name).await,
|
|
Command::Register { server } => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_register(&server, &args.ca_cert, &args.server_name))
|
|
.await
|
|
}
|
|
Command::FetchKey {
|
|
server,
|
|
identity_key,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_fetch_key(
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
&identity_key,
|
|
))
|
|
.await
|
|
}
|
|
Command::DemoGroup { server } => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_demo_group(&server, &args.ca_cert, &args.server_name))
|
|
.await
|
|
}
|
|
Command::RegisterState { state, server } => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_register_state(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
state_pw,
|
|
))
|
|
.await
|
|
}
|
|
Command::RefreshKeypackage { state, server } => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_refresh_keypackage(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
state_pw,
|
|
))
|
|
.await
|
|
}
|
|
Command::CreateGroup {
|
|
state,
|
|
server,
|
|
group_id,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_create_group(&state, &server, &group_id, state_pw))
|
|
.await
|
|
}
|
|
Command::Invite {
|
|
state,
|
|
server,
|
|
peer_key,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_invite(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
&peer_key,
|
|
state_pw,
|
|
))
|
|
.await
|
|
}
|
|
Command::Join { state, server } => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_join(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
state_pw,
|
|
))
|
|
.await
|
|
}
|
|
Command::Send {
|
|
state,
|
|
server,
|
|
peer_key,
|
|
all,
|
|
msg,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_send(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
peer_key.as_deref(),
|
|
all,
|
|
&msg,
|
|
state_pw,
|
|
))
|
|
.await
|
|
}
|
|
Command::Recv {
|
|
state,
|
|
server,
|
|
wait_ms,
|
|
stream,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_recv(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
wait_ms,
|
|
stream,
|
|
state_pw,
|
|
))
|
|
.await
|
|
}
|
|
Command::Repl {
|
|
state,
|
|
server,
|
|
username,
|
|
password,
|
|
no_server,
|
|
} => {
|
|
let state = derive_state_path(state, username.as_deref());
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(run_repl(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
state_pw,
|
|
username.as_deref(),
|
|
password.as_deref(),
|
|
&args.access_token,
|
|
args.device_id.as_deref(),
|
|
no_server,
|
|
))
|
|
.await
|
|
}
|
|
Command::Chat {
|
|
state,
|
|
server,
|
|
peer_key,
|
|
poll_interval_ms,
|
|
} => {
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(cmd_chat(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
peer_key.as_deref(),
|
|
state_pw,
|
|
poll_interval_ms,
|
|
))
|
|
.await
|
|
}
|
|
#[cfg(feature = "tui")]
|
|
Command::Tui {
|
|
state,
|
|
server,
|
|
username,
|
|
password,
|
|
} => {
|
|
let state = derive_state_path(state, username.as_deref());
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(run_tui(
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
state_pw,
|
|
username.as_deref(),
|
|
password.as_deref(),
|
|
&args.access_token,
|
|
args.device_id.as_deref(),
|
|
))
|
|
.await
|
|
}
|
|
Command::Export {
|
|
conv_db,
|
|
conv_id,
|
|
output,
|
|
transcript_password,
|
|
db_password,
|
|
} => {
|
|
// Prompt for transcript password if not provided.
|
|
let tp = match transcript_password {
|
|
Some(p) => p,
|
|
None => {
|
|
eprint!("Transcript password: ");
|
|
rpassword::read_password()
|
|
.context("failed to read transcript password")?
|
|
}
|
|
};
|
|
cmd_export(
|
|
&conv_db,
|
|
&conv_id,
|
|
&output,
|
|
&tp,
|
|
db_password.as_deref().or(state_pw),
|
|
)
|
|
}
|
|
Command::ExportVerify { input } => cmd_export_verify(&input),
|
|
#[cfg(feature = "playbook")]
|
|
Command::Run {
|
|
playbook,
|
|
state,
|
|
server,
|
|
username,
|
|
password,
|
|
vars,
|
|
} => {
|
|
let state = derive_state_path(state, username.as_deref());
|
|
let local = tokio::task::LocalSet::new();
|
|
local
|
|
.run_until(run_playbook(
|
|
&playbook,
|
|
&state,
|
|
&server,
|
|
&args.ca_cert,
|
|
&args.server_name,
|
|
state_pw,
|
|
username.as_deref(),
|
|
password.as_deref(),
|
|
&args.access_token,
|
|
args.device_id.as_deref(),
|
|
&vars,
|
|
))
|
|
.await
|
|
}
|
|
}
|
|
}
|