v0.0.21: TUI overhaul, WZP call infrastructure, security hardening, federation
TUI:
- Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod)
- Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge
- /help command, terminal bell on incoming DM, /devices + /kick commands
- 44 unit tests (types, input, draw with TestBackend)
Server — WZP Call Infrastructure (FC-2/3/5/6/7/10):
- Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees)
- WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline)
- Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out)
- Presence API (GET /presence/:fp, POST /presence/batch)
- Missed call flush on WS reconnect
- WZP relay config + CORS
Server — Security (FC-P1):
- Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers)
- Session auto-recovery (delete corrupted ratchet, show [session reset])
- WS connection cap (5/fingerprint) + global concurrency limit (200)
- Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all)
Server — Federation:
- Two-server federation via JSON config (--federation flag)
- Periodic presence sync (every 5s, full-state, self-healing)
- Message forwarding via HTTP POST with SHA-256(secret||body) auth
- Graceful degradation (peer down = queue locally)
- deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs
Client — Group Messaging:
- SenderKeyDistribution storage + GroupSenderKey decryption in TUI
- sender_keys sled tree in LocalDb
WASM:
- All 8 WireMessage variants handled (no more "unsupported")
- decrypt_group_message() + create_sender_key_from_distribution() exports
- CallSignal parsing with signal_type mapping
Docs:
- ARCHITECTURE.md rewritten with Mermaid diagrams
- README.md created
- TASK_PLAN.md with FC-P{phase}-T{task} naming
- PROGRESS.md updated to v0.0.21
WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,26 @@ use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
/// Maximum WebSocket connections per fingerprint (multi-device cap).
|
||||
const MAX_WS_PER_FINGERPRINT: usize = 5;
|
||||
|
||||
/// Maximum number of message IDs to track for deduplication.
|
||||
const DEDUP_CAPACITY: usize = 10_000;
|
||||
|
||||
/// Per-connection sender: messages are pushed here for instant delivery.
|
||||
pub type WsSender = mpsc::UnboundedSender<Vec<u8>>;
|
||||
|
||||
/// Connected clients: fingerprint → list of WS senders (multiple devices).
|
||||
pub type Connections = Arc<Mutex<HashMap<String, Vec<WsSender>>>>;
|
||||
/// Metadata for a single connected device.
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceConnection {
|
||||
pub device_id: String,
|
||||
pub sender: WsSender,
|
||||
pub connected_at: i64,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// Connected clients: fingerprint → list of device connections (multiple devices).
|
||||
pub type Connections = Arc<Mutex<HashMap<String, Vec<DeviceConnection>>>>;
|
||||
|
||||
/// Bounded dedup tracker: FIFO eviction when capacity is exceeded.
|
||||
#[derive(Clone)]
|
||||
@@ -47,11 +59,35 @@ impl DedupTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Call lifecycle status.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum CallStatus {
|
||||
Ringing,
|
||||
Active,
|
||||
Ended,
|
||||
}
|
||||
|
||||
/// Server-side state for an active or recently ended call.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CallState {
|
||||
pub call_id: String,
|
||||
pub caller_fp: String,
|
||||
pub callee_fp: String,
|
||||
pub group_name: Option<String>,
|
||||
pub room_id: Option<String>,
|
||||
pub status: CallStatus,
|
||||
pub created_at: i64,
|
||||
pub answered_at: Option<i64>,
|
||||
pub ended_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub connections: Connections,
|
||||
pub dedup: DedupTracker,
|
||||
pub active_calls: Arc<Mutex<HashMap<String, CallState>>>,
|
||||
pub federation: Option<crate::federation::FederationHandle>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -61,16 +97,18 @@ impl AppState {
|
||||
db: Arc::new(db),
|
||||
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||
dedup: DedupTracker::new(),
|
||||
active_calls: Arc::new(Mutex::new(HashMap::new())),
|
||||
federation: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Try to push a message to a connected client. Returns true if delivered.
|
||||
pub async fn push_to_client(&self, fingerprint: &str, message: &[u8]) -> bool {
|
||||
let conns = self.connections.lock().await;
|
||||
if let Some(senders) = conns.get(fingerprint) {
|
||||
if let Some(devices) = conns.get(fingerprint) {
|
||||
let mut delivered = false;
|
||||
for sender in senders {
|
||||
if sender.send(message.to_vec()).is_ok() {
|
||||
for device in devices {
|
||||
if device.sender.send(message.to_vec()).is_ok() {
|
||||
delivered = true;
|
||||
}
|
||||
}
|
||||
@@ -81,25 +119,127 @@ impl AppState {
|
||||
}
|
||||
|
||||
/// Register a WS connection for a fingerprint.
|
||||
pub async fn register_ws(&self, fingerprint: &str) -> mpsc::UnboundedReceiver<Vec<u8>> {
|
||||
///
|
||||
/// Returns `None` if the per-fingerprint connection cap has been reached.
|
||||
/// On success, returns the assigned device ID and a receiver for push messages.
|
||||
pub async fn register_ws(&self, fingerprint: &str, token: Option<String>) -> Option<(String, mpsc::UnboundedReceiver<Vec<u8>>)> {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let device_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
|
||||
let mut conns = self.connections.lock().await;
|
||||
conns.entry(fingerprint.to_string()).or_default().push(tx);
|
||||
tracing::info!("WS registered for {} ({} total connections)", fingerprint,
|
||||
conns.values().map(|v| v.len()).sum::<usize>());
|
||||
rx
|
||||
let entry = conns.entry(fingerprint.to_string()).or_default();
|
||||
|
||||
// Clean up closed connections first
|
||||
entry.retain(|d| !d.sender.is_closed());
|
||||
|
||||
if entry.len() >= MAX_WS_PER_FINGERPRINT {
|
||||
tracing::warn!(
|
||||
"WS connection cap reached for {} ({} connections)",
|
||||
fingerprint,
|
||||
entry.len()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
entry.push(DeviceConnection {
|
||||
device_id: device_id.clone(),
|
||||
sender: tx,
|
||||
connected_at: chrono::Utc::now().timestamp(),
|
||||
token,
|
||||
});
|
||||
tracing::info!(
|
||||
"WS registered for {} device={} ({} total)",
|
||||
fingerprint,
|
||||
device_id,
|
||||
conns.values().map(|v| v.len()).sum::<usize>()
|
||||
);
|
||||
Some((device_id, rx))
|
||||
}
|
||||
|
||||
/// Unregister a WS connection.
|
||||
#[allow(dead_code)]
|
||||
pub async fn unregister_ws(&self, fingerprint: &str, sender: &WsSender) {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(senders) = conns.get_mut(fingerprint) {
|
||||
senders.retain(|s| !s.same_channel(sender));
|
||||
if senders.is_empty() {
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
devices.retain(|d| !d.sender.same_channel(sender));
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
}
|
||||
tracing::info!("WS unregistered for {}", fingerprint);
|
||||
}
|
||||
|
||||
/// Try to deliver a message: local push → federation forward → DB queue.
|
||||
/// Returns true if delivered instantly (local or remote).
|
||||
pub async fn deliver_or_queue(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||
// 1. Try local WebSocket push
|
||||
if self.push_to_client(to_fp, message).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Try federation forward
|
||||
if let Some(ref federation) = self.federation {
|
||||
if federation.is_remote(to_fp).await {
|
||||
if federation.forward_message(to_fp, message).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Queue in local DB
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = self.db.messages.insert(key.as_bytes(), message);
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a fingerprint has any active WS connections.
|
||||
pub async fn is_online(&self, fingerprint: &str) -> bool {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint).map(|d| !d.is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Count active WS connections for a fingerprint (multi-device).
|
||||
pub async fn device_count(&self, fingerprint: &str) -> usize {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint).map(|d| d.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// List devices for a fingerprint with metadata.
|
||||
pub async fn list_devices(&self, fingerprint: &str) -> Vec<(String, i64)> {
|
||||
let conns = self.connections.lock().await;
|
||||
conns.get(fingerprint)
|
||||
.map(|devices| devices.iter().map(|d| (d.device_id.clone(), d.connected_at)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Kick a specific device by ID. Returns true if found and kicked.
|
||||
pub async fn kick_device(&self, fingerprint: &str, device_id: &str) -> bool {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
let before = devices.len();
|
||||
devices.retain(|d| d.device_id != device_id);
|
||||
let kicked = devices.len() < before;
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
kicked
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke all connections for a fingerprint except one device_id.
|
||||
pub async fn revoke_all_except(&self, fingerprint: &str, keep_device_id: &str) -> usize {
|
||||
let mut conns = self.connections.lock().await;
|
||||
if let Some(devices) = conns.get_mut(fingerprint) {
|
||||
let before = devices.len();
|
||||
devices.retain(|d| d.device_id == keep_device_id);
|
||||
let removed = before - devices.len();
|
||||
if devices.is_empty() {
|
||||
conns.remove(fingerprint);
|
||||
}
|
||||
removed
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user