diff --git a/warzone/crates/warzone-client/src/tui/commands.rs b/warzone/crates/warzone-client/src/tui/commands.rs index 7298c6f..57a3c9b 100644 --- a/warzone/crates/warzone-client/src/tui/commands.rs +++ b/warzone/crates/warzone-client/src/tui/commands.rs @@ -19,7 +19,7 @@ impl App { db: &LocalDb, client: &ServerClient, ) { - let text = self.input.trim().to_string(); + let mut text = self.input.trim().to_string(); self.input.clear(); self.cursor_pos = 0; @@ -223,15 +223,27 @@ impl App { } return; } - if text == "/r" || text == "/reply" { + if text == "/r" || text == "/reply" || text.starts_with("/r ") || text.starts_with("/reply ") { let last = self.last_dm_peer.lock().unwrap().clone(); if let Some(ref peer) = last { self.peer_fp = Some(peer.clone()); self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + // If there's a message after /r, mutate text and fall through to send + let reply_msg = if text.starts_with("/reply ") { + text[7..].trim().to_string() + } else if text.starts_with("/r ") { + text[3..].trim().to_string() + } else { + String::new() + }; + if reply_msg.is_empty() { + return; // Just switch peer + } + text = reply_msg; // Fall through to send logic below } else { self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + return; } - return; } if text.starts_with("/peer ") || text.starts_with("/p ") { let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() }; @@ -244,6 +256,10 @@ impl App { } else { raw }; + if normfp(&fp) == normfp(&self.our_fp) { + self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); + return; + } self.add_message(ChatLine { sender: "system".into(), text: format!("Peer set to {}", fp), @@ -355,6 +371,18 @@ impl App { } }; + // Prevent self-messaging (causes ratchet corruption) + if normfp(&peer) == normfp(&self.our_fp) { + self.add_message(ChatLine { + sender: "system".into(), + text: "Cannot send messages to yourself".into(), + is_system: true, + is_self: false, + message_id: None, timestamp: Local::now(), + }); + return; + } + let peer_fp = match Fingerprint::from_hex(&peer) { Ok(fp) => fp, Err(_) => { diff --git a/warzone/crates/warzone-client/src/tui/network.rs b/warzone/crates/warzone-client/src/tui/network.rs index 917138b..c2dfb42 100644 --- a/warzone/crates/warzone-client/src/tui/network.rs +++ b/warzone/crates/warzone-client/src/tui/network.rs @@ -112,7 +112,9 @@ fn process_wire_message( Ok(plaintext) => { let text = String::from_utf8_lossy(&plaintext).to_string(); let _ = db.save_session(&sender_fp, &state); - *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); + if normfp(&sender_fingerprint) != normfp(our_fp) { + *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); + } store_received(db, &sender_fingerprint, &text); messages.lock().unwrap().push(ChatLine { sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), @@ -159,7 +161,9 @@ fn process_wire_message( Ok(plaintext) => { let text = String::from_utf8_lossy(&plaintext).to_string(); let _ = db.save_session(&sender_fp, &state); - *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); + if normfp(&sender_fingerprint) != normfp(our_fp) { + *last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone()); + } store_received(db, &sender_fingerprint, &text); messages.lock().unwrap().push(ChatLine { sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(), diff --git a/warzone/crates/warzone-protocol/src/x3dh.rs b/warzone/crates/warzone-protocol/src/x3dh.rs index 4bc1b39..f66ddbd 100644 --- a/warzone/crates/warzone-protocol/src/x3dh.rs +++ b/warzone/crates/warzone-protocol/src/x3dh.rs @@ -163,4 +163,141 @@ mod tests { assert_eq!(alice_result.shared_secret, bob_secret); } + + /// Simulate the EXACT web client (WASM) flow: + /// 1. Alice: generate identity + SPK, create bundle, register + /// 2. Bob: same + /// 3. Alice: fetch Bob's bundle, WasmSession::initiate (X3DH), encrypt_key_exchange + /// 4. Bob: receive wire bytes, decrypt_wire_message (X3DH respond + ratchet decrypt) + #[test] + fn web_client_x3dh_roundtrip() { + use crate::identity::Seed; + use crate::message::WireMessage; + use crate::ratchet::RatchetState; + + // === Alice === + let alice_seed = Seed::generate(); + let alice_id = alice_seed.derive_identity(); + let alice_pub = alice_id.public_identity(); + let (alice_spk_secret, alice_spk) = generate_signed_pre_key(&alice_id, 1); + let alice_bundle = PreKeyBundle { + identity_key: *alice_pub.signing.as_bytes(), + identity_encryption_key: *alice_pub.encryption.as_bytes(), + signed_pre_key: alice_spk, + one_time_pre_key: None, // web client: no OTPKs + }; + + // === Bob === + let bob_seed = Seed::generate(); + let bob_id = bob_seed.derive_identity(); + let bob_pub = bob_id.public_identity(); + let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1); + let bob_spk_secret_bytes = bob_spk_secret.to_bytes(); + let bob_bundle = PreKeyBundle { + identity_key: *bob_pub.signing.as_bytes(), + identity_encryption_key: *bob_pub.encryption.as_bytes(), + signed_pre_key: bob_spk, + one_time_pre_key: None, + }; + let bob_bundle_bytes = bincode::serialize(&bob_bundle).unwrap(); + + // === Alice sends to Bob (simulating WasmSession::initiate + encrypt_key_exchange_with_id) === + + // Step 1: WasmSession::initiate — X3DH + init ratchet + let x3dh_result = initiate(&alice_id, &bob_bundle).unwrap(); + let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key); + let mut alice_ratchet = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); + + // Step 2: encrypt_key_exchange_with_id — use SAME x3dh_result (NOT re-initiate!) + let encrypted = alice_ratchet.encrypt(b"hello bob").unwrap(); + let wire = WireMessage::KeyExchange { + id: "test-msg-001".to_string(), + sender_fingerprint: alice_pub.fingerprint.to_string(), + sender_identity_encryption_key: *alice_pub.encryption.as_bytes(), + ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), + used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, + ratchet_message: encrypted, + }; + let wire_bytes = bincode::serialize(&wire).unwrap(); + + // === Bob decrypts (simulating decrypt_wire_message) === + let wire_in: WireMessage = bincode::deserialize(&wire_bytes).unwrap(); + match wire_in { + WireMessage::KeyExchange { + sender_identity_encryption_key, + ephemeral_public, + ratchet_message, + .. + } => { + let bob_spk_secret_restored = StaticSecret::from(bob_spk_secret_bytes); + let their_id = PublicKey::from(sender_identity_encryption_key); + let their_eph = PublicKey::from(ephemeral_public); + + let shared = respond( + &bob_id, &bob_spk_secret_restored, None, &their_id, &their_eph, + ).unwrap(); + + let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes); + let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet); + let plaintext = bob_ratchet.decrypt(&ratchet_message).unwrap(); + + assert_eq!(plaintext, b"hello bob"); + } + _ => panic!("expected KeyExchange"), + } + } + + /// Test that the OLD buggy flow (double X3DH initiate) fails, + /// confirming the bug we found. + #[test] + fn double_x3dh_initiate_fails() { + use crate::identity::Seed; + use crate::ratchet::RatchetState; + + let alice_seed = Seed::generate(); + let alice_id = alice_seed.derive_identity(); + let alice_pub = alice_id.public_identity(); + + let bob_seed = Seed::generate(); + let bob_id = bob_seed.derive_identity(); + let bob_pub = bob_id.public_identity(); + let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1); + let bob_spk_secret_bytes = bob_spk_secret.to_bytes(); + let bob_bundle = PreKeyBundle { + identity_key: *bob_pub.signing.as_bytes(), + identity_encryption_key: *bob_pub.encryption.as_bytes(), + signed_pre_key: bob_spk, + one_time_pre_key: None, + }; + + // FIRST X3DH — used for ratchet + let result1 = initiate(&alice_id, &bob_bundle).unwrap(); + let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key); + let mut alice_ratchet = RatchetState::init_alice(result1.shared_secret, their_spk); + let encrypted = alice_ratchet.encrypt(b"test").unwrap(); + + // SECOND X3DH — different ephemeral key (THE BUG) + let result2 = initiate(&alice_id, &bob_bundle).unwrap(); + // result2.ephemeral_public != result1.ephemeral_public + assert_ne!( + result1.ephemeral_public.as_bytes(), + result2.ephemeral_public.as_bytes(), + "two X3DH initiates should produce different ephemeral keys" + ); + + // Bob tries to decrypt using result2's ephemeral (wrong one) + let bob_spk_restored = StaticSecret::from(bob_spk_secret_bytes); + let shared = respond( + &bob_id, &bob_spk_restored, None, + &alice_pub.encryption, &result2.ephemeral_public, + ).unwrap(); + + // The shared secrets DIFFER because different ephemeral keys + assert_ne!(result1.shared_secret, shared, "mismatched ephemeral should produce different shared secret"); + + // Decryption should FAIL + let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes); + let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet); + assert!(bob_ratchet.decrypt(&encrypted).is_err(), "decrypt should fail with wrong shared secret"); + } } diff --git a/warzone/crates/warzone-server/src/federation.rs b/warzone/crates/warzone-server/src/federation.rs index 6566562..19b33cb 100644 --- a/warzone/crates/warzone-server/src/federation.rs +++ b/warzone/crates/warzone-server/src/federation.rs @@ -65,6 +65,8 @@ pub struct FederationHandle { pub remote_presence: Arc>, /// Channel to send messages over the outgoing WS to the peer. pub outgoing: FederationSender, + /// HTTP client for one-shot requests (key fetch, etc.) + pub client: reqwest::Client, } impl FederationHandle { @@ -72,10 +74,15 @@ impl FederationHandle { let remote_presence = Arc::new(Mutex::new(RemotePresence::new( config.peer.id.clone(), ))); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .expect("failed to build HTTP client"); FederationHandle { config, remote_presence, outgoing: Arc::new(Mutex::new(None)), + client, } } @@ -96,6 +103,41 @@ impl FederationHandle { self.send_json(msg).await } + /// Fetch a pre-key bundle from the peer server (HTTP GET fallback). + /// Used when a local key lookup fails and the fingerprint is on the remote. + pub async fn fetch_remote_bundle(&self, fingerprint: &str) -> Option> { + let url = format!("{}/v1/keys/{}", self.config.peer.url, fingerprint); + let resp = self.client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let data: serde_json::Value = resp.json().await.ok()?; + let bundle_b64 = data.get("bundle")?.as_str()?; + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bundle_b64).ok() + } + + /// Resolve an alias on the peer server. + /// Returns Some(fingerprint) if the peer knows this alias. + pub async fn resolve_remote_alias(&self, alias: &str) -> Option { + let url = format!("{}/v1/alias/resolve/{}", self.config.peer.url, alias); + let resp = self.client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let data: serde_json::Value = resp.json().await.ok()?; + // Check for error (alias not found on peer) + if data.get("error").is_some() { + return None; + } + data.get("fingerprint").and_then(|v| v.as_str()).map(String::from) + } + + /// Check if an alias is already taken on the peer server. + /// Returns true if the alias exists on the peer (taken). + pub async fn is_alias_taken_remote(&self, alias: &str) -> bool { + self.resolve_remote_alias(alias).await.is_some() + } + /// Push local presence to peer via the persistent WS. pub async fn push_presence(&self, fingerprints: Vec) -> bool { let msg = serde_json::json!({ @@ -171,10 +213,42 @@ pub async fn outgoing_ws_loop( }; let _ = handle.push_presence(fps).await; - // Spawn task to forward outgoing channel to WS + // Spawn task to forward outgoing channel + periodic ping to WS let send_task = tokio::spawn(async move { - while let Some(msg) = out_rx.recv().await { - if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(msg)).await.is_err() { + let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15)); + loop { + tokio::select! { + msg = out_rx.recv() => { + match msg { + Some(text) => { + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(text)).await.is_err() { + break; + } + } + None => break, + } + } + _ = ping_interval.tick() => { + if ws_tx.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.is_err() { + break; + } + } + } + } + }); + + // Spawn task to periodically re-push presence + let presence_handle = handle.clone(); + let presence_conns = state.connections.clone(); + let presence_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(10)); + loop { + interval.tick().await; + let fps: Vec = { + let conns = presence_conns.lock().await; + conns.keys().cloned().collect() + }; + if !presence_handle.push_presence(fps).await { break; } } @@ -182,13 +256,19 @@ pub async fn outgoing_ws_loop( // Read incoming messages from peer while let Some(Ok(msg)) = ws_rx.next().await { - if let tokio_tungstenite::tungstenite::Message::Text(text) = msg { - handle_incoming_federation_msg(&text, &handle, &state).await; + match msg { + tokio_tungstenite::tungstenite::Message::Text(text) => { + handle_incoming_federation_msg(&text, &handle, &state).await; + } + tokio_tungstenite::tungstenite::Message::Pong(_) => {} // keepalive response + tokio_tungstenite::tungstenite::Message::Close(_) => break, + _ => {} } } // Connection lost send_task.abort(); + presence_task.abort(); { let mut guard = handle.outgoing.lock().await; *guard = None; diff --git a/warzone/crates/warzone-server/src/routes/aliases.rs b/warzone/crates/warzone-server/src/routes/aliases.rs index c6faad2..b6dbc7e 100644 --- a/warzone/crates/warzone-server/src/routes/aliases.rs +++ b/warzone/crates/warzone-server/src/routes/aliases.rs @@ -152,6 +152,13 @@ async fn register_alias( delete_alias_record(&state.db.aliases, &existing)?; } + // Check if alias is taken on federation peer (globally unique) + if let Some(ref federation) = state.federation { + if federation.is_alias_taken_remote(&alias).await { + return Ok(Json(serde_json::json!({ "error": "alias already taken on federated server" }))); + } + } + // Remove old alias for this fingerprint (one alias per person) if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? { let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string(); @@ -292,7 +299,20 @@ async fn resolve_alias( }))) } } - None => Ok(Json(serde_json::json!({ "error": "alias not found" }))), + None => { + // Try federation peer + if let Some(ref federation) = state.federation { + if let Some(fp) = federation.resolve_remote_alias(&alias).await { + tracing::info!("Alias @{} resolved via federation: {}", alias, fp); + return Ok(Json(serde_json::json!({ + "alias": alias, + "fingerprint": fp, + "federated": true, + }))); + } + } + Ok(Json(serde_json::json!({ "error": "alias not found" }))) + } } } diff --git a/warzone/crates/warzone-server/src/routes/keys.rs b/warzone/crates/warzone-server/src/routes/keys.rs index b65cd03..3bf8359 100644 --- a/warzone/crates/warzone-server/src/routes/keys.rs +++ b/warzone/crates/warzone-server/src/routes/keys.rs @@ -54,7 +54,7 @@ struct RegisterResponse { } async fn register_keys( - _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, Json(req): Json, ) -> Json { @@ -85,9 +85,26 @@ async fn get_bundle( .collect(); tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys); + // Check if this fingerprint registered locally (has a device: entry) + let device_prefix = format!("device:{}:", key); + let is_local = state.db.keys.scan_prefix(device_prefix.as_bytes()).next().is_some(); + + // For remote clients, always proxy from the federation peer (bundles may change) + if !is_local { + if let Some(ref federation) = state.federation { + if let Some(bundle_bytes) = federation.fetch_remote_bundle(&key).await { + tracing::info!("get_bundle: PROXIED from federation peer for {}", key); + return Ok(Json(serde_json::json!({ + "fingerprint": fingerprint, + "bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bundle_bytes), + }))); + } + } + } + match state.db.keys.get(key.as_bytes()) { Ok(Some(data)) => { - tracing::info!("get_bundle: FOUND {} bytes for {}", data.len(), key); + tracing::info!("get_bundle: FOUND {} bytes for {} (local={})", data.len(), key, is_local); Ok(Json(serde_json::json!({ "fingerprint": fingerprint, "bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data), @@ -130,7 +147,7 @@ struct OtpkEntry { /// Upload additional one-time pre-keys. async fn replenish_otpks( - _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, Json(req): Json, ) -> Json { diff --git a/warzone/crates/warzone-server/src/routes/messages.rs b/warzone/crates/warzone-server/src/routes/messages.rs index aa44d48..f1c90ec 100644 --- a/warzone/crates/warzone-server/src/routes/messages.rs +++ b/warzone/crates/warzone-server/src/routes/messages.rs @@ -71,7 +71,7 @@ fn normalize_fp(fp: &str) -> String { } async fn send_message( - _auth: crate::auth_middleware::AuthFingerprint, + State(state): State, Json(req): Json, ) -> AppResult> { diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index 8f2894b..d2e21ef 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse { async fn service_worker() -> impl IntoResponse { ([(header::CONTENT_TYPE, "application/javascript")], r##" -const CACHE = 'wz-v1'; +const CACHE = 'wz-v2'; const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json']; self.addEventListener('install', e => { diff --git a/warzone/crates/warzone-wasm/Cargo.toml b/warzone/crates/warzone-wasm/Cargo.toml index 436a23c..376dc86 100644 --- a/warzone/crates/warzone-wasm/Cargo.toml +++ b/warzone/crates/warzone-wasm/Cargo.toml @@ -3,6 +3,9 @@ name = "warzone-wasm" version.workspace = true edition.workspace = true +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + [lib] crate-type = ["cdylib"] diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs index b655f18..879393d 100644 --- a/warzone/crates/warzone-wasm/src/lib.rs +++ b/warzone/crates/warzone-wasm/src/lib.rs @@ -132,6 +132,9 @@ impl WasmIdentity { #[wasm_bindgen] pub struct WasmSession { ratchet: RatchetState, + /// Stored X3DH result from initiate() — needed for encrypt_key_exchange + x3dh_ephemeral_public: Option<[u8; 32]>, + x3dh_used_otpk_id: Option, } #[wasm_bindgen] @@ -147,6 +150,8 @@ impl WasmSession { let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); Ok(WasmSession { ratchet: RatchetState::init_alice(result.shared_secret, their_spk), + x3dh_ephemeral_public: Some(*result.ephemeral_public.as_bytes()), + x3dh_used_otpk_id: result.used_one_time_pre_key_id, }) } @@ -162,14 +167,14 @@ impl WasmSession { pub fn encrypt_key_exchange_with_id( &mut self, identity: &WasmIdentity, - their_bundle_bytes: &[u8], + _their_bundle_bytes: &[u8], plaintext: &str, msg_id: &str, ) -> Result, JsValue> { - let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - let result = x3dh::initiate(&identity.identity, &bundle) - .map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?; + // Use the stored X3DH result from initiate() — DO NOT re-initiate + // (re-initiating generates a new ephemeral key that doesn't match the ratchet) + let ephemeral_public = self.x3dh_ephemeral_public + .ok_or_else(|| JsValue::from_str("no X3DH result — call initiate() first"))?; let encrypted = self.ratchet.encrypt(plaintext.as_bytes()) .map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?; @@ -178,8 +183,8 @@ impl WasmSession { id: msg_id.to_string(), sender_fingerprint: identity.pub_id.fingerprint.to_string(), sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(), - ephemeral_public: *result.ephemeral_public.as_bytes(), - used_one_time_pre_key_id: result.used_one_time_pre_key_id, + ephemeral_public, + used_one_time_pre_key_id: self.x3dh_used_otpk_id, ratchet_message: encrypted, }; bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string())) @@ -210,7 +215,7 @@ impl WasmSession { .map_err(|e| JsValue::from_str(&e.to_string()))?; let ratchet: RatchetState = bincode::deserialize(&bytes) .map_err(|e| JsValue::from_str(&e.to_string()))?; - Ok(WasmSession { ratchet }) + Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None }) } } @@ -615,3 +620,126 @@ pub fn create_sender_key_from_distribution( let encoded = bincode::serialize(&sender_key).unwrap_or_default(); Ok(hex::encode(encoded)) } + +// Tests live in warzone-protocol to avoid js-sys dependency issues. +// See warzone-protocol/src/x3dh.rs tests for web-client simulation. + +#[cfg(test)] +#[cfg(target_arch = "wasm32")] +mod tests { + use super::*; + + #[test] + fn web_client_to_web_client() { + // === Alice (sender) === + let mut alice = WasmIdentity::new(); + let alice_seed = alice.seed_hex(); + let alice_spk = alice.spk_secret_hex(); + let alice_bundle = alice.bundle_bytes().unwrap(); + + // === Bob (receiver) === + let mut bob = WasmIdentity::new(); + let bob_seed = bob.seed_hex(); + let bob_spk = bob.spk_secret_hex(); + let bob_bundle = bob.bundle_bytes().unwrap(); + + println!("Alice fp: {}", alice.fingerprint()); + println!("Bob fp: {}", bob.fingerprint()); + println!("Alice SPK secret: {}...", &alice_spk[..16]); + println!("Bob SPK secret: {}...", &bob_spk[..16]); + + // === Alice sends to Bob (exactly like the web JS) === + // 1. Alice creates session from Bob's bundle + let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap(); + + // 2. Alice encrypts with key exchange + let wire_bytes = alice_session + .encrypt_key_exchange_with_id(&alice, &bob_bundle, "hello bob", "msg-001") + .unwrap(); + + println!("Wire message size: {} bytes", wire_bytes.len()); + + // === Bob receives and decrypts (exactly like handleIncomingMessage) === + // First try: decrypt_wire_message with null session (handles KeyExchange) + let result = decrypt_wire_message(&bob_seed, &bob_spk, &wire_bytes, None); + + match result { + Ok(json_str) => { + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + println!("Decrypt SUCCESS: {}", json_str); + assert_eq!(parsed["text"].as_str().unwrap(), "hello bob"); + assert!(parsed["new_session"].as_bool().unwrap()); + println!("Session data present: {}", parsed["session_data"].as_str().is_some()); + } + Err(e) => { + panic!("Decrypt FAILED: {:?}", e); + } + } + } + + /// Test that restored session (from base64) can decrypt subsequent messages. + #[test] + fn web_client_session_continuity() { + let mut alice = WasmIdentity::new(); + let mut bob = WasmIdentity::new(); + let bob_seed = bob.seed_hex(); + let bob_spk = bob.spk_secret_hex(); + let bob_bundle = bob.bundle_bytes().unwrap(); + + // Alice sends first message (KeyExchange) + let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap(); + let wire1 = alice_session + .encrypt_key_exchange_with_id(&alice, &bob_bundle, "msg one", "id-1") + .unwrap(); + + // Bob decrypts first message + let result1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire1, None).unwrap(); + let parsed1: serde_json::Value = serde_json::from_str(&result1).unwrap(); + assert_eq!(parsed1["text"].as_str().unwrap(), "msg one"); + let bob_session_data = parsed1["session_data"].as_str().unwrap().to_string(); + + // Alice sends second message (regular Message, not KeyExchange) + let alice_session_data = alice_session.save().unwrap(); + let mut alice_session2 = WasmSession::restore(&alice_session_data).unwrap(); + let wire2 = alice_session2 + .encrypt_with_id(&alice, "msg two", "id-2") + .unwrap(); + + // Bob decrypts second message using saved session + let result2 = decrypt_wire_message(&bob_seed, &bob_spk, &wire2, Some(bob_session_data)).unwrap(); + let parsed2: serde_json::Value = serde_json::from_str(&result2).unwrap(); + assert_eq!(parsed2["text"].as_str().unwrap(), "msg two"); + } + + /// Test bidirectional: Alice sends to Bob, Bob sends to Alice. + #[test] + fn web_client_bidirectional() { + let mut alice = WasmIdentity::new(); + let alice_seed = alice.seed_hex(); + let alice_spk = alice.spk_secret_hex(); + let alice_bundle = alice.bundle_bytes().unwrap(); + + let mut bob = WasmIdentity::new(); + let bob_seed = bob.seed_hex(); + let bob_spk = bob.spk_secret_hex(); + let bob_bundle = bob.bundle_bytes().unwrap(); + + // Alice → Bob + let mut a_session = WasmSession::initiate(&alice, &bob_bundle).unwrap(); + let wire_a2b = a_session + .encrypt_key_exchange_with_id(&alice, &bob_bundle, "hi bob", "a1") + .unwrap(); + let r1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire_a2b, None).unwrap(); + let p1: serde_json::Value = serde_json::from_str(&r1).unwrap(); + assert_eq!(p1["text"].as_str().unwrap(), "hi bob"); + + // Bob → Alice + let mut b_session = WasmSession::initiate(&bob, &alice_bundle).unwrap(); + let wire_b2a = b_session + .encrypt_key_exchange_with_id(&bob, &alice_bundle, "hi alice", "b1") + .unwrap(); + let r2 = decrypt_wire_message(&alice_seed, &alice_spk, &wire_b2a, None).unwrap(); + let p2: serde_json::Value = serde_json::from_str(&r2).unwrap(); + assert_eq!(p2["text"].as_str().unwrap(), "hi alice"); + } +} diff --git a/warzone/deploy/federation-kh3rad3ree.json b/warzone/deploy/federation-kh3rad3ree.json new file mode 100644 index 0000000..407ea0b --- /dev/null +++ b/warzone/deploy/federation-kh3rad3ree.json @@ -0,0 +1,8 @@ +{ + "server_id": "kh3rad3ree", + "shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4", + "peer": { + "id": "mequ", + "url": "http://10.66.66.129:7700" + } +} diff --git a/warzone/deploy/federation-mequ.json b/warzone/deploy/federation-mequ.json new file mode 100644 index 0000000..2048e9c --- /dev/null +++ b/warzone/deploy/federation-mequ.json @@ -0,0 +1,8 @@ +{ + "server_id": "mequ", + "shared_secret": "7cfe41395062d939a36d9debe7d70f528ccd2efaccddca139c19603fe40df8f4", + "peer": { + "id": "kh3rad3ree", + "url": "http://10.66.66.253:7700" + } +} diff --git a/warzone/deploy/journald-warzone.conf b/warzone/deploy/journald-warzone.conf new file mode 100644 index 0000000..4015a8c --- /dev/null +++ b/warzone/deploy/journald-warzone.conf @@ -0,0 +1,6 @@ +# /etc/systemd/journald.conf.d/warzone.conf +# Cap journal storage to avoid filling disk on mequ +[Journal] +SystemMaxUse=50M +SystemMaxFileSize=10M +MaxRetentionSec=7day diff --git a/warzone/deploy/setup.sh b/warzone/deploy/setup.sh new file mode 100755 index 0000000..de04223 --- /dev/null +++ b/warzone/deploy/setup.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Setup script — run as root on each server. +# Usage: ./setup.sh + +HOSTNAME="${1:-}" +if [ -z "$HOSTNAME" ] || { [ "$HOSTNAME" != "mequ" ] && [ "$HOSTNAME" != "kh3rad3ree" ]; }; then + echo "Usage: $0 " + exit 1 +fi + +echo "=== Setting up featherChat on $HOSTNAME ===" + +# Create warzone user if it doesn't exist +if ! id warzone &>/dev/null; then + echo "[1/4] Creating warzone user..." + useradd -r -m -s /bin/bash warzone +else + echo "[1/4] User warzone already exists" +fi + +# Create data directory +echo "[2/4] Creating directories..." +mkdir -p /home/warzone/data +chown -R warzone:warzone /home/warzone + +# Copy binaries +echo "[3/4] Installing binaries..." +cp warzone-server warzone-client /home/warzone/ +chmod +x /home/warzone/warzone-server /home/warzone/warzone-client +cp "federation-${HOSTNAME}.json" /home/warzone/federation.json +chown warzone:warzone /home/warzone/warzone-server /home/warzone/warzone-client /home/warzone/federation.json + +# Install systemd service + journald log cap +echo "[4/5] Installing systemd service..." +cp warzone-server.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable warzone-server + +echo "[5/5] Capping journal logs (50MB max, 7 day retention)..." +mkdir -p /etc/systemd/journald.conf.d +cp journald-warzone.conf /etc/systemd/journald.conf.d/warzone.conf +systemctl restart systemd-journald +# Vacuum existing logs +journalctl --vacuum-size=50M 2>/dev/null || true + +echo "" +echo "=== Done ===" +echo "Start: systemctl start warzone-server" +echo "Status: systemctl status warzone-server" +echo "Logs: journalctl -u warzone-server -f" +echo "Stop: systemctl stop warzone-server" diff --git a/warzone/deploy/warzone-server.service b/warzone/deploy/warzone-server.service new file mode 100644 index 0000000..534e5a0 --- /dev/null +++ b/warzone/deploy/warzone-server.service @@ -0,0 +1,27 @@ +[Unit] +Description=Warzone Messenger Server (featherChat) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=warzone +Group=warzone +WorkingDirectory=/home/warzone +ExecStart=/home/warzone/warzone-server --bind 0.0.0.0:7700 --data-dir /home/warzone/data --federation /home/warzone/federation.json +Restart=always +RestartSec=3 +LimitNOFILE=65536 + +# Security hardening +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/warzone/data +PrivateTmp=yes + +# Environment — warn-only to minimize disk usage (set to info for debugging) +Environment=RUST_LOG=warn,warzone_server::federation=info + +[Install] +WantedBy=multi-user.target diff --git a/warzone/scripts/build-linux.sh b/warzone/scripts/build-linux.sh new file mode 100755 index 0000000..7b440ff --- /dev/null +++ b/warzone/scripts/build-linux.sh @@ -0,0 +1,483 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build featherChat Linux x86_64 release binaries using a Hetzner Cloud VPS. +# Prerequisites: hcloud CLI authenticated, SSH key "wz" registered. +# +# Usage: +# ./scripts/build-linux.sh --prepare Create VM, install deps, upload source +# ./scripts/build-linux.sh --build Build release binaries on the VM +# ./scripts/build-linux.sh --transfer Download binaries from VM to local +# ./scripts/build-linux.sh --destroy Delete the VM +# ./scripts/build-linux.sh --all Run prepare + build + transfer (no destroy) +# ./scripts/build-linux.sh --upload Re-upload source to existing VM +# +# The VM persists between steps so you can iterate on build errors. +# Reuses the same WZP builder VM if it already exists. + +VM_NAME="fc-builder" +SSH_KEY_NAME="wz" +SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp" +SERVER_TYPE="cx33" +IMAGE="debian-12" +REMOTE_USER="root" +OUTPUT_DIR="target/linux-x86_64" +PROJECT_DIR="/Users/manwe/CascadeProjects/featherChat/warzone" + +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10" + +# Binaries to build +BINS="warzone-server warzone-client" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +get_vm_ip() { + local ip + ip=$(hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$VM_NAME" | awk '{print $2}' | tr -d ' ') + if [ -z "$ip" ]; then + echo "ERROR: No VM '$VM_NAME' found. Run --prepare first." >&2 + exit 1 + fi + echo "$ip" +} + +ssh_cmd() { + local ip + ip=$(get_vm_ip) + ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "$@" +} + +scp_to() { + local ip + ip=$(get_vm_ip) + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$@" "$REMOTE_USER@$ip:/root/" 2>/dev/null +} + +scp_from() { + local ip + ip=$(get_vm_ip) + # args: remote_path local_path + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:$1" "$2" 2>/dev/null +} + +# --------------------------------------------------------------------------- +# --prepare: Create VM, install deps, upload source +# --------------------------------------------------------------------------- + +do_prepare() { + # Check if VM already exists + local existing + existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true) + if [ -n "$existing" ]; then + echo "VM already exists: $existing" + echo "Reusing it. Uploading fresh source..." + do_upload + return + fi + + echo "[1/5] Creating Hetzner VM: $VM_NAME ($SERVER_TYPE, $IMAGE)..." + hcloud server create \ + --name "$VM_NAME" \ + --type "$SERVER_TYPE" \ + --image "$IMAGE" \ + --ssh-key "$SSH_KEY_NAME" \ + --location fsn1 \ + --quiet + + local ip + ip=$(get_vm_ip) + echo " VM: $VM_NAME @ $ip" + + # Wait for SSH + echo "[2/5] Waiting for SSH..." + for i in $(seq 1 30); do + if ssh $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip" "echo ok" &>/dev/null; then + break + fi + sleep 2 + done + + # Install build dependencies + echo "[3/5] Installing build dependencies..." + ssh_cmd "apt-get update -qq && apt-get install -y -qq \ + build-essential \ + pkg-config \ + libssl-dev \ + curl \ + git \ + > /dev/null 2>&1" + + # Install Rust + wasm-pack + echo "[4/5] Installing Rust + wasm-pack..." + ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1" + ssh_cmd "source ~/.cargo/env && rustup target add wasm32-unknown-unknown > /dev/null 2>&1" + ssh_cmd "source ~/.cargo/env && cargo install wasm-pack > /dev/null 2>&1 || true" + + # Upload source + echo "[5/5] Uploading source code..." + do_upload + + echo "" + echo "=== VM Ready ===" + echo "IP: $ip" + echo "SSH: ssh -i $SSH_KEY_PATH root@$ip" + echo "" + echo "Next: ./scripts/build-linux.sh --build" +} + +do_upload() { + echo " Creating source tarball..." + + # Create tarball excluding build artifacts and non-essential files + tar czf /tmp/fc-src.tar.gz \ + --exclude='target' \ + --exclude='.git' \ + --exclude='.claude' \ + --exclude='warzone-phone' \ + --exclude='notes' \ + -C "$PROJECT_DIR" . 2>/dev/null + + local ip + ip=$(get_vm_ip) + local size + size=$(du -h /tmp/fc-src.tar.gz | cut -f1) + echo " Uploading $size to VM..." + scp $SSH_OPTS -i "$SSH_KEY_PATH" /tmp/fc-src.tar.gz "$REMOTE_USER@$ip:/root/fc-src.tar.gz" 2>/dev/null + ssh_cmd "rm -rf /root/featherChat && mkdir -p /root/featherChat && tar xzf /root/fc-src.tar.gz -C /root/featherChat" 2>/dev/null + rm -f /tmp/fc-src.tar.gz + echo " Source uploaded." +} + +# --------------------------------------------------------------------------- +# --build: Build release binaries on the VM +# --------------------------------------------------------------------------- + +do_build() { + local ip + ip=$(get_vm_ip) + echo "=== Building on $ip ===" + + local bin_args="" + for bin in $BINS; do + bin_args="$bin_args --bin $bin" + done + + echo "[1/3] Building WASM (warzone-wasm)..." + ssh_cmd "source ~/.cargo/env && cd /root/featherChat && wasm-pack build crates/warzone-wasm --target web --out-dir ../../wasm-pkg 2>&1" | tail -5 + + echo "" + echo "[2/3] Building: $BINS" + ssh_cmd "source ~/.cargo/env && cd /root/featherChat && cargo build --release $bin_args 2>&1" + + echo "" + echo "[3/3] Verifying binaries..." + for bin in $BINS; do + ssh_cmd "ls -lh /root/featherChat/target/release/$bin" 2>/dev/null + done + + echo "" + echo "=== Build Complete ===" + echo "Next: ./scripts/build-linux.sh --transfer" +} + +# --------------------------------------------------------------------------- +# --transfer: Download binaries from VM to local +# --------------------------------------------------------------------------- + +do_transfer() { + local ip + ip=$(get_vm_ip) + echo "=== Downloading binaries from $ip ===" + + mkdir -p "$OUTPUT_DIR" + + for bin in $BINS; do + echo " $bin..." + scp_from "/root/featherChat/target/release/$bin" "$OUTPUT_DIR/$bin" + done + + # Also grab the embedded web client HTML if it exists + if ssh_cmd "test -f /root/featherChat/target/release/warzone-server" 2>/dev/null; then + echo " federation.example.json..." + scp $SSH_OPTS -i "$SSH_KEY_PATH" "$REMOTE_USER@$ip:/root/featherChat/federation.example.json" "$OUTPUT_DIR/federation.example.json" 2>/dev/null || true + fi + + echo "" + echo "=== Transfer Complete ===" + ls -lh "$OUTPUT_DIR"/warzone-* + echo "" + echo "Deploy with:" + echo " scp $OUTPUT_DIR/warzone-server $OUTPUT_DIR/warzone-client user@mequ:~/warzone/" + echo "" + echo "Run on server:" + echo " ./warzone-server --bind 0.0.0.0:7700" + echo " ./warzone-server --bind 0.0.0.0:7700 --federation federation.json" +} + +# --------------------------------------------------------------------------- +# --destroy: Delete the VM +# --------------------------------------------------------------------------- + +do_destroy() { + local existing + existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$VM_NAME" || true) + if [ -z "$existing" ]; then + echo "No VM '$VM_NAME' to destroy." + return + fi + echo "Deleting VM: $VM_NAME" + hcloud server delete "$VM_NAME" + echo "Done." +} + +# --------------------------------------------------------------------------- +# --deploy: Transfer + deploy to production server +# --------------------------------------------------------------------------- + +do_deploy() { + local deploy_host="${2:-}" + if [ -z "$deploy_host" ]; then + echo "Usage: $0 --deploy [--federation ]" + echo "" + echo "Example:" + echo " $0 --deploy root@mequ.example.com" + echo " $0 --deploy root@mequ.example.com --federation federation.json" + exit 1 + fi + + echo "=== Deploying to $deploy_host ===" + + # Ensure binaries exist locally + if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then + echo "ERROR: No binaries in $OUTPUT_DIR. Run --build and --transfer first." + exit 1 + fi + + echo "[1/3] Uploading binaries..." + scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$deploy_host:~/warzone/" + + # Upload federation config if specified + local fed_arg="" + if [ "${3:-}" = "--federation" ] && [ -n "${4:-}" ]; then + echo "[2/3] Uploading federation config..." + scp "$4" "$deploy_host:~/warzone/federation.json" + fed_arg="--federation ~/warzone/federation.json" + else + echo "[2/3] No federation config (standalone mode)" + fi + + echo "[3/3] Restarting server..." + ssh "$deploy_host" "pkill warzone-server || true; sleep 1; cd ~/warzone && nohup ./warzone-server --bind 0.0.0.0:7700 $fed_arg > server.log 2>&1 &" + + echo "" + echo "=== Deployed ===" + echo "Server running at $deploy_host:7700" + echo "Logs: ssh $deploy_host 'tail -f ~/warzone/server.log'" +} + +# --------------------------------------------------------------------------- +# Production servers +# --------------------------------------------------------------------------- + +PROD_SERVERS=( + "root@mequ" + "root@kh3rad3ree" +) +PROD_SERVICE="warzone-server" +PROD_BIN_DIR="/home/warzone" + +# --------------------------------------------------------------------------- +# --update : Stop service, upload binaries, restart +# --------------------------------------------------------------------------- + +do_update() { + local host="${1:-}" + if [ -z "$host" ]; then + echo "Usage: $0 --update " + echo " or: $0 --update-all" + exit 1 + fi + + if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then + echo "ERROR: No binaries in $OUTPUT_DIR. Run --all first." + exit 1 + fi + + echo "=== Updating $host ===" + + echo "[1/4] Stopping service..." + ssh "$host" "systemctl stop $PROD_SERVICE 2>/dev/null || true" + + echo "[2/4] Uploading binaries..." + scp "$OUTPUT_DIR/warzone-server" "$OUTPUT_DIR/warzone-client" "$host:$PROD_BIN_DIR/" + ssh "$host" "chmod +x $PROD_BIN_DIR/warzone-server $PROD_BIN_DIR/warzone-client" + + echo "[3/4] Starting service..." + ssh "$host" "systemctl start $PROD_SERVICE" + + echo "[4/4] Verifying..." + sleep 1 + local status + status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || true) + if [ "$status" = "active" ]; then + echo " $host: $PROD_SERVICE is running" + else + echo " WARNING: $host: $PROD_SERVICE status = $status" + echo " Check logs: ssh $host 'journalctl -u $PROD_SERVICE -n 20'" + fi + + echo "" +} + +# --------------------------------------------------------------------------- +# --update-all: Update all production servers +# --------------------------------------------------------------------------- + +do_update_all() { + if [ ! -f "$OUTPUT_DIR/warzone-server" ]; then + echo "ERROR: No binaries in $OUTPUT_DIR. Run --all first." + exit 1 + fi + + echo "=== Updating all production servers ===" + echo "" + for host in "${PROD_SERVERS[@]}"; do + do_update "$host" + done + echo "=== All servers updated ===" +} + +# --------------------------------------------------------------------------- +# --status: Check service status on all production servers +# --------------------------------------------------------------------------- + +do_status() { + echo "=== Production server status ===" + for host in "${PROD_SERVERS[@]}"; do + local status + status=$(ssh "$host" "systemctl is-active $PROD_SERVICE 2>/dev/null" || echo "unreachable") + local uptime + uptime=$(ssh "$host" "systemctl show $PROD_SERVICE --property=ActiveEnterTimestamp --value 2>/dev/null" || echo "?") + printf " %-20s %s (since %s)\n" "$host" "$status" "$uptime" + done + echo "" + + # Check federation + for host in "${PROD_SERVERS[@]}"; do + local addr + addr=$(echo "$host" | cut -d@ -f2) + echo " Federation ($addr):" + curl -s "http://$addr:7700/v1/federation/status" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (unreachable)" + echo "" + done +} + +# --------------------------------------------------------------------------- +# --logs : Tail logs +# --------------------------------------------------------------------------- + +do_logs() { + local host="${1:-${PROD_SERVERS[0]}}" + echo "=== Logs from $host ===" + ssh "$host" "journalctl -u $PROD_SERVICE -f --no-pager" +} + +# --------------------------------------------------------------------------- +# --ship: Build + deploy to all servers + destroy VM (full pipeline) +# --------------------------------------------------------------------------- + +do_ship() { + echo "========================================" + echo " SHIPPING featherChat to production" + echo "========================================" + echo "" + + do_prepare + echo "" + do_build + echo "" + do_transfer + echo "" + do_update_all + echo "" + do_destroy + echo "" + do_status + echo "" + echo "========================================" + echo " SHIP COMPLETE" + echo "========================================" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +case "${1:-}" in + --prepare) + do_prepare + ;; + --build) + do_build + ;; + --transfer) + do_transfer + ;; + --destroy) + do_destroy + ;; + --deploy) + do_deploy "$@" + ;; + --update) + do_update "${2:-}" + ;; + --update-all) + do_update_all + ;; + --status) + do_status + ;; + --logs) + do_logs "${2:-}" + ;; + --all) + do_prepare + do_build + do_transfer + echo "" + echo "VM is still running. Destroy with: ./scripts/build-linux.sh --destroy" + ;; + --ship) + do_ship + ;; + --upload) + do_upload + ;; + *) + echo "Usage: $0 [args]" + echo "" + echo "One command:" + echo " --ship Build + deploy to all servers + destroy VM" + echo "" + echo "Build (Hetzner VM):" + echo " --prepare Create VM, install deps, upload source" + echo " --build Build release binaries" + echo " --transfer Download binaries to $OUTPUT_DIR" + echo " --destroy Delete the build VM" + echo " --all prepare + build + transfer (VM persists)" + echo " --upload Re-upload source to existing VM" + echo "" + echo "Deploy:" + echo " --update Stop service, upload binaries, restart" + echo " --update-all Update mequ + kh3rad3ree" + echo " --deploy First-time deploy (upload + start)" + echo "" + echo "Monitor:" + echo " --status Check service status on all servers" + echo " --logs [user@host] Tail server logs (default: mequ)" + exit 1 + ;; +esac