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:
@@ -112,6 +112,7 @@ struct RegisterRequest {
|
||||
/// - Expired aliases (past grace period) can be reclaimed by anyone
|
||||
/// - Expired aliases (within grace period) can only be reclaimed by recovery key
|
||||
async fn register_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -190,6 +191,7 @@ struct RecoverRequest {
|
||||
|
||||
/// Recover an alias using the recovery key. Works even if expired (within or past grace).
|
||||
async fn recover_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RecoverRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -244,6 +246,7 @@ struct RenewRequest {
|
||||
|
||||
/// Renew/heartbeat — resets the TTL. Called automatically on activity.
|
||||
async fn renew_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RenewRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -347,6 +350,7 @@ struct UnregisterRequest {
|
||||
|
||||
/// Remove your own alias.
|
||||
async fn unregister_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UnregisterRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -381,6 +385,7 @@ struct AdminRemoveRequest {
|
||||
|
||||
/// Admin: remove any alias.
|
||||
async fn admin_remove_alias(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<AdminRemoveRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
|
||||
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
233
warzone/crates/warzone-server/src/routes/calls.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::{AppState, CallState, CallStatus};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/calls/initiate", post(initiate_call))
|
||||
.route("/calls/:id", get(get_call))
|
||||
.route("/calls/:id/end", post(end_call))
|
||||
.route("/calls/active", get(active_calls))
|
||||
.route("/calls/missed", post(get_missed_calls))
|
||||
.route("/groups/:name/call", post(initiate_group_call))
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InitiateRequest {
|
||||
caller: String,
|
||||
callee: String,
|
||||
}
|
||||
|
||||
async fn initiate_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<InitiateRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let call_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let call = CallState {
|
||||
call_id: call_id.clone(),
|
||||
caller_fp: normalize_fp(&req.caller),
|
||||
callee_fp: normalize_fp(&req.callee),
|
||||
group_name: None,
|
||||
room_id: None,
|
||||
status: CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
|
||||
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
tracing::info!("Call initiated: {} -> {}", call.caller_fp, call.callee_fp);
|
||||
Ok(Json(serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"status": "ringing",
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_call(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// Try in-memory first, then DB
|
||||
if let Some(call) = state.active_calls.lock().await.get(&id) {
|
||||
return Ok(Json(serde_json::to_value(call)?));
|
||||
}
|
||||
if let Some(data) = state.db.calls.get(id.as_bytes())? {
|
||||
let call: CallState = serde_json::from_slice(&data)?;
|
||||
return Ok(Json(serde_json::to_value(&call)?));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "call not found" })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EndCallRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
async fn end_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<EndCallRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let _fp = normalize_fp(&req.fingerprint);
|
||||
let mut calls = state.active_calls.lock().await;
|
||||
if let Some(mut call) = calls.remove(&id) {
|
||||
call.status = CallStatus::Ended;
|
||||
call.ended_at = Some(now);
|
||||
state.db.calls.insert(id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
return Ok(Json(serde_json::json!({ "ok": true, "call_id": id })));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "error": "call not found or already ended" })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ActiveQuery {
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
async fn active_calls(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ActiveQuery>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let calls = state.active_calls.lock().await;
|
||||
let filtered: Vec<&CallState> = match q.fingerprint {
|
||||
Some(ref fp) => {
|
||||
let fp = normalize_fp(fp);
|
||||
calls.values().filter(|c| c.caller_fp == fp || c.callee_fp == fp).collect()
|
||||
}
|
||||
None => calls.values().collect(),
|
||||
};
|
||||
Ok(Json(serde_json::json!({ "calls": filtered })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MissedRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
async fn get_missed_calls(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<MissedRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&req.fingerprint);
|
||||
let prefix = format!("missed:{}", fp);
|
||||
let mut missed = Vec::new();
|
||||
let mut keys = Vec::new();
|
||||
for (key, value) in state.db.missed_calls.scan_prefix(prefix.as_bytes()).flatten() {
|
||||
if let Ok(record) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||
missed.push(record);
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
// Delete after reading
|
||||
for key in &keys {
|
||||
let _ = state.db.missed_calls.remove(key);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "missed_calls": missed })))
|
||||
}
|
||||
|
||||
// --- FC-5: Group call ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GroupCallRequest {
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
/// Deterministic room ID from group name: hex(SHA-256("featherchat-group:" + name)[:16])
|
||||
fn hash_room_name(group_name: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(format!("featherchat-group:{}", group_name).as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
hex::encode(&hash[..16])
|
||||
}
|
||||
|
||||
async fn initiate_group_call(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<GroupCallRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let caller_fp = normalize_fp(&req.fingerprint);
|
||||
|
||||
// Load group
|
||||
let group_data = match state.db.groups.get(name.as_bytes())? {
|
||||
Some(d) => d,
|
||||
None => return Ok(Json(serde_json::json!({ "error": "group not found" }))),
|
||||
};
|
||||
let group: serde_json::Value = serde_json::from_slice(&group_data)?;
|
||||
let members: Vec<String> = group.get("members")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Verify caller is a member
|
||||
if !members.contains(&caller_fp) {
|
||||
return Ok(Json(serde_json::json!({ "error": "not a member of this group" })));
|
||||
}
|
||||
|
||||
let room_id = hash_room_name(&name);
|
||||
let call_id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// Create call state
|
||||
let call = CallState {
|
||||
call_id: call_id.clone(),
|
||||
caller_fp: caller_fp.clone(),
|
||||
callee_fp: "group".to_string(),
|
||||
group_name: Some(name.clone()),
|
||||
room_id: Some(room_id.clone()),
|
||||
status: CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state.active_calls.lock().await.insert(call_id.clone(), call.clone());
|
||||
state.db.calls.insert(call_id.as_bytes(), serde_json::to_vec(&call)?.as_slice())?;
|
||||
|
||||
// Fan out CallSignal::Offer to all online members (except caller)
|
||||
let offer = warzone_protocol::message::WireMessage::CallSignal {
|
||||
id: call_id.clone(),
|
||||
sender_fingerprint: caller_fp.clone(),
|
||||
signal_type: warzone_protocol::message::CallSignalType::Offer,
|
||||
payload: serde_json::json!({ "room_id": room_id, "group": name }).to_string(),
|
||||
target: format!("#{}", name),
|
||||
};
|
||||
let encoded = bincode::serialize(&offer)?;
|
||||
|
||||
let mut delivered = 0;
|
||||
for member in &members {
|
||||
if *member == caller_fp { continue; }
|
||||
if state.push_to_client(member, &encoded).await {
|
||||
delivered += 1;
|
||||
} else {
|
||||
// Queue for offline members
|
||||
let key = format!("queue:{}:{}", member, uuid::Uuid::new_v4());
|
||||
state.db.messages.insert(key.as_bytes(), encoded.as_slice())?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Group call #{}: room={}, caller={}, notified={}/{}",
|
||||
name, room_id, caller_fp, delivered, members.len() - 1);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"room_id": room_id,
|
||||
"group": name,
|
||||
"members_notified": delivered,
|
||||
"members_total": members.len() - 1,
|
||||
})))
|
||||
}
|
||||
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
102
warzone/crates/warzone-server/src/routes/devices.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::auth_middleware::AuthFingerprint;
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/:id/kick", post(kick_device))
|
||||
.route("/devices/revoke-all", post(revoke_all))
|
||||
}
|
||||
|
||||
/// List active WS connections for the authenticated user.
|
||||
async fn list_devices(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let devices = state.list_devices(&auth.fingerprint).await;
|
||||
let list: Vec<serde_json::Value> = devices
|
||||
.iter()
|
||||
.map(|(id, connected_at)| {
|
||||
serde_json::json!({
|
||||
"device_id": id,
|
||||
"connected_at": connected_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let count = list.len();
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": auth.fingerprint,
|
||||
"devices": list,
|
||||
"count": count,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Kick a specific device by ID. Requires auth -- only the device owner can kick.
|
||||
async fn kick_device(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path(device_id): axum::extract::Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let kicked = state.kick_device(&auth.fingerprint, &device_id).await;
|
||||
if kicked {
|
||||
tracing::info!("Device {} kicked by {}", device_id, auth.fingerprint);
|
||||
Ok(Json(serde_json::json!({ "ok": true, "kicked": device_id })))
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({ "error": "device not found" })))
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke all sessions except the current one. Panic button.
|
||||
async fn revoke_all(
|
||||
auth: AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let keep_device = req
|
||||
.get("keep_device_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let removed = state
|
||||
.revoke_all_except(&auth.fingerprint, keep_device)
|
||||
.await;
|
||||
|
||||
// Also clear all tokens for this fingerprint except the current one
|
||||
// Scan tokens tree for this fingerprint
|
||||
let mut tokens_to_remove = Vec::new();
|
||||
for item in state.db.tokens.iter().flatten() {
|
||||
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&item.1) {
|
||||
if val.get("fingerprint").and_then(|v| v.as_str()) == Some(&auth.fingerprint) {
|
||||
tokens_to_remove.push(item.0.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only remove tokens if we actually revoked devices
|
||||
let tokens_cleared = if removed > 0 {
|
||||
let count = tokens_to_remove.len();
|
||||
for key in &tokens_to_remove {
|
||||
let _ = state.db.tokens.remove(key);
|
||||
}
|
||||
count
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Revoke-all for {}: {} devices removed, {} tokens cleared",
|
||||
auth.fingerprint,
|
||||
removed,
|
||||
tokens_cleared,
|
||||
);
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"devices_removed": removed,
|
||||
"tokens_cleared": tokens_cleared,
|
||||
})))
|
||||
}
|
||||
144
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
144
warzone/crates/warzone-server/src/routes/federation.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Federation route handlers: receive presence updates and forwarded messages from peer server.
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/federation/presence", post(receive_presence))
|
||||
.route("/federation/forward", post(receive_forward))
|
||||
.route("/federation/status", axum::routing::get(federation_status))
|
||||
}
|
||||
|
||||
/// Extract and validate the federation token from headers.
|
||||
fn validate_request(state: &AppState, headers: &HeaderMap, body: &[u8]) -> Result<(), (StatusCode, String)> {
|
||||
let federation = state.federation.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "federation not configured".to_string()))?;
|
||||
|
||||
let token = headers.get("x-federation-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "missing X-Federation-Token header".to_string()))?;
|
||||
|
||||
if !crate::federation::verify_token(&federation.config.shared_secret, body, token) {
|
||||
return Err((StatusCode::UNAUTHORIZED, "invalid federation token".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive presence announcement from peer.
|
||||
/// POST /v1/federation/presence
|
||||
/// Body: { "server_id": "...", "fingerprints": [...], "timestamp": ... }
|
||||
async fn receive_presence(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
if let Err((status, msg)) = validate_request(&state, &headers, &body) {
|
||||
return (status, Json(serde_json::json!({ "error": msg }))).into_response();
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid JSON: {}", e) }))).into_response(),
|
||||
};
|
||||
|
||||
let fingerprints: Vec<String> = parsed.get("fingerprints")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let server_id = parsed.get("server_id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
|
||||
if let Some(ref federation) = state.federation {
|
||||
let mut rp = federation.remote_presence.lock().await;
|
||||
let count = fingerprints.len();
|
||||
rp.fingerprints = fingerprints.into_iter().collect();
|
||||
rp.last_updated = chrono::Utc::now().timestamp();
|
||||
tracing::debug!("Federation: received {} fingerprints from {}", count, server_id);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
|
||||
}
|
||||
|
||||
/// Receive a forwarded message from peer.
|
||||
/// POST /v1/federation/forward
|
||||
/// Body: { "to": "fingerprint", "message": "base64...", "from_server": "..." }
|
||||
async fn receive_forward(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
if let Err((status, msg)) = validate_request(&state, &headers, &body) {
|
||||
return (status, Json(serde_json::json!({ "error": msg }))).into_response();
|
||||
}
|
||||
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid JSON: {}", e) }))).into_response(),
|
||||
};
|
||||
|
||||
let to = match parsed.get("to").and_then(|v| v.as_str()) {
|
||||
Some(fp) => fp.to_string(),
|
||||
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "missing 'to' field" }))).into_response(),
|
||||
};
|
||||
|
||||
let message_b64 = match parsed.get("message").and_then(|v| v.as_str()) {
|
||||
Some(m) => m.to_string(),
|
||||
None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "missing 'message' field" }))).into_response(),
|
||||
};
|
||||
|
||||
let message = match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &message_b64) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("invalid base64: {}", e) }))).into_response(),
|
||||
};
|
||||
|
||||
let from_server = parsed.get("from_server").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
|
||||
// Try to deliver locally
|
||||
let delivered = state.push_to_client(&to, &message).await;
|
||||
if !delivered {
|
||||
// Queue for later pickup
|
||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||
let _ = state.db.messages.insert(key.as_bytes(), message.as_slice());
|
||||
tracing::info!("Federation: queued forwarded message from {} for offline user {}", from_server, to);
|
||||
} else {
|
||||
tracing::info!("Federation: delivered forwarded message from {} to {}", from_server, to);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "delivered": delivered }))).into_response()
|
||||
}
|
||||
|
||||
/// Federation health status.
|
||||
/// GET /v1/federation/status
|
||||
async fn federation_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
match state.federation {
|
||||
Some(ref federation) => {
|
||||
let rp = federation.remote_presence.lock().await;
|
||||
Json(serde_json::json!({
|
||||
"enabled": true,
|
||||
"server_id": federation.config.server_id,
|
||||
"peer_id": federation.config.peer.id,
|
||||
"peer_url": federation.config.peer.url,
|
||||
"peer_alive": rp.is_alive(federation.config.presence_interval_secs),
|
||||
"remote_clients": rp.fingerprints.len(),
|
||||
"last_sync": rp.last_updated,
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
Json(serde_json::json!({
|
||||
"enabled": false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ fn save_group(db: &sled::Tree, group: &GroupInfo) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -99,6 +100,7 @@ async fn create_group(
|
||||
}
|
||||
|
||||
async fn join_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<JoinRequest>,
|
||||
@@ -169,6 +171,7 @@ async fn list_groups(
|
||||
/// queue infrastructure — group messages look like 1:1 messages to the
|
||||
/// recipient, but with a group tag.
|
||||
async fn send_to_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<GroupSendRequest>,
|
||||
@@ -210,6 +213,7 @@ async fn send_to_group(
|
||||
}
|
||||
|
||||
async fn leave_group(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<JoinRequest>,
|
||||
@@ -235,6 +239,7 @@ struct KickRequest {
|
||||
}
|
||||
|
||||
async fn kick_member(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<KickRequest>,
|
||||
|
||||
@@ -54,6 +54,7 @@ struct RegisterResponse {
|
||||
}
|
||||
|
||||
async fn register_keys(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Json<RegisterResponse> {
|
||||
@@ -129,6 +130,7 @@ struct OtpkEntry {
|
||||
|
||||
/// Upload additional one-time pre-keys.
|
||||
async fn replenish_otpks(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ReplenishRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
|
||||
@@ -71,6 +71,7 @@ fn normalize_fp(fp: &str) -> String {
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SendRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
@@ -84,14 +85,11 @@ async fn send_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Try WebSocket push first (instant delivery)
|
||||
if state.push_to_client(&to, &req.message).await {
|
||||
tracing::info!("Pushed message to {} via WS ({} bytes)", to, req.message.len());
|
||||
let delivered = state.deliver_or_queue(&to, &req.message).await;
|
||||
if delivered {
|
||||
tracing::info!("Delivered message to {} ({} bytes)", to, req.message.len());
|
||||
} else {
|
||||
// Queue in DB (offline delivery)
|
||||
let key = format!("queue:{}:{}", to, uuid::Uuid::new_v4());
|
||||
tracing::info!("Queuing message for {} ({} bytes)", to, req.message.len());
|
||||
state.db.messages.insert(key.as_bytes(), req.message)?;
|
||||
tracing::info!("Queued message for {} ({} bytes)", to, req.message.len());
|
||||
}
|
||||
|
||||
// Renew sender's alias TTL (sending = authenticated action)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
mod aliases;
|
||||
pub mod auth;
|
||||
mod calls;
|
||||
mod devices;
|
||||
mod federation;
|
||||
mod groups;
|
||||
mod health;
|
||||
mod keys;
|
||||
pub mod messages;
|
||||
mod presence;
|
||||
mod web;
|
||||
mod ws;
|
||||
mod wzp;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
@@ -20,6 +25,11 @@ pub fn router() -> Router<AppState> {
|
||||
.merge(aliases::routes())
|
||||
.merge(auth::routes())
|
||||
.merge(ws::routes())
|
||||
.merge(calls::routes())
|
||||
.merge(devices::routes())
|
||||
.merge(presence::routes())
|
||||
.merge(wzp::routes())
|
||||
.merge(federation::routes())
|
||||
}
|
||||
|
||||
/// Web UI router (served at root, outside /v1)
|
||||
|
||||
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
57
warzone/crates/warzone-server/src/routes/presence.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/presence/:fingerprint", get(get_presence))
|
||||
.route("/presence/batch", post(batch_presence))
|
||||
}
|
||||
|
||||
fn normalize_fp(fp: &str) -> String {
|
||||
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
|
||||
}
|
||||
|
||||
async fn get_presence(
|
||||
State(state): State<AppState>,
|
||||
Path(fingerprint): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let fp = normalize_fp(&fingerprint);
|
||||
let online = state.is_online(&fp).await;
|
||||
let devices = state.device_count(&fp).await;
|
||||
Ok(Json(serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"online": online,
|
||||
"devices": devices,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BatchRequest {
|
||||
fingerprints: Vec<String>,
|
||||
}
|
||||
|
||||
async fn batch_presence(
|
||||
_auth: crate::auth_middleware::AuthFingerprint,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BatchRequest>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let mut results = Vec::new();
|
||||
for fp in &req.fingerprints {
|
||||
let fp = normalize_fp(fp);
|
||||
let online = state.is_online(&fp).await;
|
||||
let devices = state.device_count(&fp).await;
|
||||
results.push(serde_json::json!({
|
||||
"fingerprint": fp,
|
||||
"online": online,
|
||||
"devices": devices,
|
||||
}));
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "results": results })))
|
||||
}
|
||||
@@ -66,16 +66,20 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
let (mut ws_tx, mut ws_rx) = socket.split();
|
||||
|
||||
// Register for push delivery
|
||||
let mut push_rx = state.register_ws(&fingerprint).await;
|
||||
let (_device_id, mut push_rx) = match state.register_ws(&fingerprint, None).await {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
tracing::warn!("WS {}: rejected — connection limit reached", fingerprint);
|
||||
return; // closes the socket
|
||||
}
|
||||
};
|
||||
|
||||
// Send any queued messages from DB
|
||||
let prefix = format!("queue:{}", fingerprint);
|
||||
let mut keys_to_delete = Vec::new();
|
||||
for item in state.db.messages.scan_prefix(prefix.as_bytes()) {
|
||||
if let Ok((key, value)) = item {
|
||||
if ws_tx.send(Message::Binary(value.to_vec().into())).await.is_ok() {
|
||||
keys_to_delete.push(key);
|
||||
}
|
||||
for (key, value) in state.db.messages.scan_prefix(prefix.as_bytes()).flatten() {
|
||||
if ws_tx.send(Message::Binary(value.to_vec())).await.is_ok() {
|
||||
keys_to_delete.push(key);
|
||||
}
|
||||
}
|
||||
for key in &keys_to_delete {
|
||||
@@ -85,11 +89,34 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
tracing::info!("WS {}: flushed {} queued messages", fingerprint, keys_to_delete.len());
|
||||
}
|
||||
|
||||
// Flush missed calls (FC-7)
|
||||
let missed_prefix = format!("missed:{}", fingerprint);
|
||||
let mut missed_keys = Vec::new();
|
||||
for (key, value) in state.db.missed_calls.scan_prefix(missed_prefix.as_bytes()).flatten() {
|
||||
if let Ok(missed) = serde_json::from_slice::<serde_json::Value>(&value) {
|
||||
let wrapper = serde_json::json!({
|
||||
"type": "missed_call",
|
||||
"data": missed,
|
||||
});
|
||||
if let Ok(json_str) = serde_json::to_string(&wrapper) {
|
||||
if ws_tx.send(Message::Text(json_str)).await.is_ok() {
|
||||
missed_keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in &missed_keys {
|
||||
let _ = state.db.missed_calls.remove(key);
|
||||
}
|
||||
if !missed_keys.is_empty() {
|
||||
tracing::info!("WS {}: flushed {} missed call notifications", fingerprint, missed_keys.len());
|
||||
}
|
||||
|
||||
// Spawn task to forward push messages to WS
|
||||
let _fp_clone = fingerprint.clone();
|
||||
let mut push_task = tokio::spawn(async move {
|
||||
while let Some(msg) = push_rx.recv().await {
|
||||
if ws_tx.send(Message::Binary(msg.into())).await.is_err() {
|
||||
if ws_tx.send(Message::Binary(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -119,13 +146,77 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
}
|
||||
}
|
||||
|
||||
// Try push to connected client first
|
||||
if !state_clone.push_to_client(&to_fp, message).await {
|
||||
// Queue in DB
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
|
||||
// Call signal side effects
|
||||
if let Ok(WireMessage::CallSignal { ref id, ref sender_fingerprint, ref signal_type, .. }) = bincode::deserialize::<WireMessage>(message) {
|
||||
use warzone_protocol::message::CallSignalType;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
match signal_type {
|
||||
CallSignalType::Offer => {
|
||||
let call = crate::state::CallState {
|
||||
call_id: id.clone(),
|
||||
caller_fp: sender_fingerprint.clone(),
|
||||
callee_fp: to_fp.clone(),
|
||||
group_name: None,
|
||||
room_id: None,
|
||||
status: crate::state::CallStatus::Ringing,
|
||||
created_at: now,
|
||||
answered_at: None,
|
||||
ended_at: None,
|
||||
};
|
||||
state_clone.active_calls.lock().await.insert(id.clone(), call.clone());
|
||||
// Persist to DB
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
tracing::info!("Call {} started: {} -> {}", id, sender_fingerprint, to_fp);
|
||||
|
||||
// If callee is offline, record missed call (FC-7)
|
||||
if !state_clone.is_online(&to_fp).await {
|
||||
let missed_key = format!("missed:{}:{}", to_fp, id);
|
||||
let missed = serde_json::json!({
|
||||
"call_id": id,
|
||||
"caller_fp": sender_fingerprint,
|
||||
"timestamp": now,
|
||||
});
|
||||
let _ = state_clone.db.missed_calls.insert(
|
||||
missed_key.as_bytes(),
|
||||
serde_json::to_vec(&missed).unwrap_or_default(),
|
||||
);
|
||||
tracing::info!("Missed call recorded for offline user {}", to_fp);
|
||||
}
|
||||
}
|
||||
CallSignalType::Answer => {
|
||||
let mut calls = state_clone.active_calls.lock().await;
|
||||
if let Some(call) = calls.get_mut(id) {
|
||||
call.status = crate::state::CallStatus::Active;
|
||||
call.answered_at = Some(now);
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
tracing::info!("Call {} answered", id);
|
||||
}
|
||||
CallSignalType::Hangup | CallSignalType::Reject => {
|
||||
let mut calls = state_clone.active_calls.lock().await;
|
||||
if let Some(mut call) = calls.remove(id) {
|
||||
call.status = crate::state::CallStatus::Ended;
|
||||
call.ended_at = Some(now);
|
||||
let _ = state_clone.db.calls.insert(
|
||||
id.as_bytes(),
|
||||
serde_json::to_vec(&call).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
tracing::info!("Call {} ended", id);
|
||||
}
|
||||
_ => {} // Ringing, Busy, IceCandidate — route opaquely
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver via local WS, federation, or queue in DB
|
||||
state_clone.deliver_or_queue(&to_fp, message).await;
|
||||
|
||||
tracing::debug!("WS {}: routed message to {}", fp_clone2, to_fp);
|
||||
}
|
||||
}
|
||||
@@ -147,10 +238,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
}
|
||||
}
|
||||
|
||||
if !state_clone.push_to_client(&to_fp, &message).await {
|
||||
let key = format!("queue:{}:{}", to_fp, uuid::Uuid::new_v4());
|
||||
let _ = state_clone.db.messages.insert(key.as_bytes(), message);
|
||||
}
|
||||
// Deliver via local WS, federation, or queue in DB
|
||||
state_clone.deliver_or_queue(&to_fp, &message).await;
|
||||
|
||||
// Renew alias TTL
|
||||
crate::routes::messages::renew_alias_ttl(
|
||||
@@ -181,9 +270,9 @@ async fn handle_socket(socket: WebSocket, state: AppState, fingerprint: String)
|
||||
// We can't easily get the sender ref here, so just clean up by fingerprint
|
||||
// In production, use a unique connection ID
|
||||
let mut conns = state.connections.lock().await;
|
||||
if let Some(senders) = conns.get_mut(&fingerprint) {
|
||||
senders.retain(|s| !s.is_closed());
|
||||
if senders.is_empty() {
|
||||
if let Some(devices) = conns.get_mut(&fingerprint) {
|
||||
devices.retain(|d| !d.sender.is_closed());
|
||||
if devices.is_empty() {
|
||||
conns.remove(&fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
45
warzone/crates/warzone-server/src/routes/wzp.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::errors::AppResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/wzp/relay-config", get(relay_config))
|
||||
}
|
||||
|
||||
/// Returns the WZP relay address and a short-lived service token.
|
||||
///
|
||||
/// The web client calls this to discover where to connect for voice/video
|
||||
/// and gets a token to present to the relay for authentication.
|
||||
async fn relay_config(
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// Issue a short-lived service token (5 minutes) for WZP relay auth.
|
||||
let token = hex::encode(rand::random::<[u8; 32]>());
|
||||
let expires = chrono::Utc::now().timestamp() + 300; // 5 minutes
|
||||
|
||||
state.db.tokens.insert(
|
||||
token.as_bytes(),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"fingerprint": "service:wzp",
|
||||
"service": "wzp",
|
||||
"expires_at": expires,
|
||||
}))?.as_slice(),
|
||||
)?;
|
||||
|
||||
// The relay address is configured server-side. For now, return a
|
||||
// placeholder that the admin sets via environment variable.
|
||||
let relay_addr = std::env::var("WZP_RELAY_ADDR")
|
||||
.unwrap_or_else(|_| "127.0.0.1:4433".to_string());
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"relay_addr": relay_addr,
|
||||
"token": token,
|
||||
"expires_in": 300,
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user