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:
212
warzone/crates/warzone-server/src/federation.rs
Normal file
212
warzone/crates/warzone-server/src/federation.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Federation: two-server message relay with shared-secret authentication.
|
||||
//!
|
||||
//! Each server periodically announces its connected clients to the peer.
|
||||
//! When a message is destined for a remote client, it's forwarded via HTTP.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
/// Federation configuration loaded from JSON.
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct FederationConfig {
|
||||
pub server_id: String,
|
||||
pub shared_secret: String,
|
||||
pub peer: PeerConfig,
|
||||
#[serde(default = "default_interval")]
|
||||
pub presence_interval_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct PeerConfig {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
fn default_interval() -> u64 { 5 }
|
||||
|
||||
/// Load federation config from a JSON file. Returns None if path is empty.
|
||||
pub fn load_config(path: &str) -> anyhow::Result<FederationConfig> {
|
||||
let data = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read federation config '{}': {}", path, e))?;
|
||||
let config: FederationConfig = serde_json::from_str(&data)
|
||||
.map_err(|e| anyhow::anyhow!("invalid federation config: {}", e))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Remote presence: which fingerprints are on the peer server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemotePresence {
|
||||
pub peer_url: String,
|
||||
pub peer_id: String,
|
||||
pub fingerprints: HashSet<String>,
|
||||
pub last_updated: i64,
|
||||
}
|
||||
|
||||
impl RemotePresence {
|
||||
pub fn new(peer_url: String, peer_id: String) -> Self {
|
||||
RemotePresence {
|
||||
peer_url,
|
||||
peer_id,
|
||||
fingerprints: HashSet::new(),
|
||||
last_updated: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a fingerprint is on the remote server.
|
||||
pub fn contains(&self, fp: &str) -> bool {
|
||||
self.fingerprints.contains(fp)
|
||||
}
|
||||
|
||||
/// Is the peer still alive? (heard from within 3 intervals)
|
||||
pub fn is_alive(&self, interval_secs: u64) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
now - self.last_updated < (interval_secs as i64 * 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle for communicating with the federation peer.
|
||||
#[derive(Clone)]
|
||||
pub struct FederationHandle {
|
||||
pub config: FederationConfig,
|
||||
pub client: reqwest::Client,
|
||||
pub remote_presence: Arc<Mutex<RemotePresence>>,
|
||||
}
|
||||
|
||||
impl FederationHandle {
|
||||
pub fn new(config: FederationConfig) -> Self {
|
||||
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
|
||||
config.peer.url.clone(),
|
||||
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, client, remote_presence }
|
||||
}
|
||||
|
||||
/// Check if a fingerprint is known to be on the peer server.
|
||||
pub async fn is_remote(&self, fp: &str) -> bool {
|
||||
let rp = self.remote_presence.lock().await;
|
||||
rp.is_alive(self.config.presence_interval_secs) && rp.contains(fp)
|
||||
}
|
||||
|
||||
/// Forward a message to the peer server for delivery.
|
||||
/// Returns true if the peer accepted it.
|
||||
pub async fn forward_message(&self, to_fp: &str, message: &[u8]) -> bool {
|
||||
let url = format!("{}/v1/federation/forward", self.config.peer.url);
|
||||
let body = serde_json::json!({
|
||||
"to": to_fp,
|
||||
"message": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, message),
|
||||
"from_server": self.config.server_id,
|
||||
});
|
||||
let body_str = serde_json::to_string(&body).unwrap_or_default();
|
||||
let token = compute_token(&self.config.shared_secret, body_str.as_bytes());
|
||||
|
||||
match self.client.post(&url)
|
||||
.header("X-Federation-Token", &token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_str)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
tracing::debug!("Federation: forwarded message to {} for {}", self.config.peer.id, to_fp);
|
||||
true
|
||||
}
|
||||
Ok(resp) => {
|
||||
tracing::warn!("Federation: peer {} rejected forward: {}", self.config.peer.id, resp.status());
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Federation: failed to forward to {}: {}", self.config.peer.id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send our local presence to the peer.
|
||||
pub async fn announce_presence(&self, fingerprints: Vec<String>) -> bool {
|
||||
let url = format!("{}/v1/federation/presence", self.config.peer.url);
|
||||
let body = serde_json::json!({
|
||||
"server_id": self.config.server_id,
|
||||
"fingerprints": fingerprints,
|
||||
"timestamp": chrono::Utc::now().timestamp(),
|
||||
});
|
||||
let body_str = serde_json::to_string(&body).unwrap_or_default();
|
||||
let token = compute_token(&self.config.shared_secret, body_str.as_bytes());
|
||||
|
||||
match self.client.post(&url)
|
||||
.header("X-Federation-Token", &token)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_str)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => true,
|
||||
Ok(resp) => {
|
||||
tracing::warn!("Federation: presence announce to {} failed: {}", self.config.peer.id, resp.status());
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Federation: presence announce to {} error: {}", self.config.peer.id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task: periodically sync presence with peer.
|
||||
pub async fn presence_sync_loop(
|
||||
handle: FederationHandle,
|
||||
connections: crate::state::Connections,
|
||||
) {
|
||||
let interval = std::time::Duration::from_secs(handle.config.presence_interval_secs);
|
||||
tracing::info!(
|
||||
"Federation: presence sync started (peer={}, interval={}s)",
|
||||
handle.config.peer.id, handle.config.presence_interval_secs
|
||||
);
|
||||
|
||||
loop {
|
||||
// Collect local fingerprints
|
||||
let fps: Vec<String> = {
|
||||
let conns = connections.lock().await;
|
||||
conns.keys().cloned().collect()
|
||||
};
|
||||
|
||||
// Announce to peer
|
||||
let ok = handle.announce_presence(fps.clone()).await;
|
||||
if ok {
|
||||
tracing::debug!("Federation: announced {} fingerprints to {}", fps.len(), handle.config.peer.id);
|
||||
}
|
||||
|
||||
// Clear stale remote presence if peer hasn't responded
|
||||
{
|
||||
let mut rp = handle.remote_presence.lock().await;
|
||||
if !rp.is_alive(handle.config.presence_interval_secs) && !rp.fingerprints.is_empty() {
|
||||
tracing::warn!("Federation: peer {} stale — clearing remote presence ({} fps)",
|
||||
handle.config.peer.id, rp.fingerprints.len());
|
||||
rp.fingerprints.clear();
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute an auth token: SHA-256(secret || body). Simple HMAC-like construction.
|
||||
pub fn compute_token(secret: &str, body: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(secret.as_bytes());
|
||||
hasher.update(body);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Verify an auth token.
|
||||
pub fn verify_token(secret: &str, body: &[u8], token: &str) -> bool {
|
||||
let expected = compute_token(secret, body);
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
expected.len() == token.len() && expected.as_bytes().iter().zip(token.as_bytes()).all(|(a, b)| a == b)
|
||||
}
|
||||
Reference in New Issue
Block a user