post webrtc intégration

This commit is contained in:
2026-09-12 21:11:14 +02:00
parent 6b8cccc082
commit 9a07b368d6
27 changed files with 2180 additions and 584 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ host = "0.0.0.0"
# TCP and UDP port can be the same
# HTTP port
tcp_port = 8080
# Voice/Video port
# WebRTC ICE/Media UDP multiplexing port
udp_port = 8080
[database]
+7 -14
View File
@@ -7,7 +7,7 @@ use crate::metrics::{AppMetrics, reporter};
use crate::repositories::Repositories;
use crate::routes::gateway::{GatewayManager, RealtimeRouter};
use crate::services::Services;
use crate::udp::server::UdpServer;
use crate::voice::VoiceService;
use event_bus::EventBus;
use migration::{Migrator, MigratorTrait};
pub use state::AppState;
@@ -65,6 +65,8 @@ impl App {
};
let metrics = AppMetrics::new();
let voice_metrics = Arc::clone(&metrics.voice);
let voice = Arc::new(VoiceService::new(&config.network, voice_metrics));
let services = Arc::new(Services::new(repositories.clone(), event_bus.clone()));
services.permission_sync.start_listen_event().await;
@@ -90,6 +92,7 @@ impl App {
gateway,
event_bus,
services,
voice,
};
Ok(Self { state })
@@ -116,28 +119,20 @@ impl App {
// Initialize HTTP Server
let (http_server, http_shutdown_tx) = HttpServer::new(&config.network, self.state.clone());
// Initialize UDP service
let udp_metrics = Arc::clone(&self.state.metrics.udp);
let (udp_server, udp_shutdown_tx) = UdpServer::new(&config.network, udp_metrics);
// Lance le reporter central de métriques toutes les 30 secondes
reporter::spawn_reporter(
Arc::new(self.state.metrics.clone()),
Duration::from_secs(30),
);
// On lance les serveurs dans des tâches séparées
// On lance le serveur HTTP dans une tâche séparée
let mut http_handle = tokio::spawn(http_server.run());
let mut udp_handle = tokio::spawn(udp_server.run());
// On arbitre : soit un signal arrive, soit une tâche se termine (erreur/crash)
tokio::select! {
res = &mut http_handle => {
tracing::error!("HTTP server stopped unexpectedly: {:?}", res);
}
res = &mut udp_handle => {
tracing::error!("UDP server stopped unexpectedly: {:?}", res);
}
_ = Self::shutdown_signal() => {
tracing::info!("Shutdown signal received, initiating graceful shutdown...");
}
@@ -145,11 +140,9 @@ impl App {
// Dans tous les cas (Ctrl-C ou crash d'un service), on demande l'arrêt global
let _ = http_shutdown_tx.send(());
let _ = udp_shutdown_tx.send(());
// On attend que tout le monde ait fini de nettoyer
// (Note: join! supporte les handles déjà terminés ou annulés)
let _ = tokio::join!(http_handle, udp_handle);
// On attend que la tâche HTTP termine
let _ = http_handle.await;
Database::checkpoint_wal(&self.state.db).await?;
Database::close(&self.state.db).await?;
+2
View File
@@ -4,6 +4,7 @@ use crate::models::server;
use crate::repositories::Repositories;
use crate::routes::gateway::GatewayManager;
use crate::services::Services;
use crate::voice::VoiceService;
use event_bus::EventBus;
use sea_orm::DatabaseConnection;
use std::sync::{Arc, RwLock};
@@ -19,6 +20,7 @@ pub struct AppState {
pub gateway: Arc<GatewayManager>,
pub event_bus: Arc<EventBus>,
pub services: Arc<Services>,
pub voice: Arc<VoiceService>,
}
impl AppState {}
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod core;
pub mod permissions;
pub mod repositories;
pub mod routes;
pub mod udp;
pub mod voice;
pub mod auth;
pub mod metrics;
+3 -3
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use std::time::Instant;
use crate::http::metrics::HttpMetrics;
use crate::udp::metrics::UdpMetrics;
use crate::voice::metrics::VoiceMetrics;
/// Contrat minimal pour un jeu de compteurs métriques.
pub trait Metrics {
@@ -26,14 +26,14 @@ pub trait MetricsSnapshot: Clone {
#[derive(Debug, Clone)]
pub struct AppMetrics {
pub http: Arc<HttpMetrics>,
pub udp: Arc<UdpMetrics>,
pub voice: Arc<VoiceMetrics>,
}
impl AppMetrics {
pub fn new() -> Self {
Self {
http: HttpMetrics::new(),
udp: UdpMetrics::new(),
voice: VoiceMetrics::new(),
}
}
}
+11 -11
View File
@@ -8,23 +8,23 @@ use crate::metrics::AppMetrics;
/// Lance une tâche tokio unique qui reporte toutes les métriques à intervalle régulier.
pub fn spawn_reporter(metrics: Arc<AppMetrics>, interval: Duration) {
let metrics_http = Arc::clone(&metrics.http);
let metrics_udp = Arc::clone(&metrics.udp);
let metrics_voice = Arc::clone(&metrics.voice);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await;
let mut prev_http = metrics_http.snapshot();
let mut prev_udp = metrics_udp.snapshot();
let mut prev_voice = metrics_voice.snapshot();
loop {
ticker.tick().await;
let current_http = metrics_http.snapshot();
let current_udp = metrics_udp.snapshot();
let current_voice = metrics_voice.snapshot();
let http_rates = current_http.rates_since(&prev_http);
let udp_rates = current_udp.rates_since(&prev_udp);
let voice_rates = current_voice.rates_since(&prev_voice);
tracing::info!(
// ── HTTP ──
@@ -34,17 +34,17 @@ pub fn spawn_reporter(metrics: Arc<AppMetrics>, interval: Duration) {
http_responses_5xx = current_http.responses_5xx,
http_req_per_sec = format_args!("{:.2}", http_rates.requests_per_sec),
http_avg_latency_ms = format_args!("{:.1}", http_rates.avg_latency_ms),
// ── UDP ──
udp_pkts_rx = current_udp.packets_received,
udp_pkts_tx = current_udp.packets_sent,
udp_pkts_dropped = current_udp.packets_dropped,
udp_pkts_rx_s = format_args!("{:.1}", udp_rates.packets_received_per_sec),
udp_pkts_tx_s = format_args!("{:.1}", udp_rates.packets_sent_per_sec),
// ── Voice ──
voice_pkts_rx = current_voice.packets_received,
voice_pkts_tx = current_voice.packets_sent,
voice_pkts_dropped = current_voice.packets_dropped,
voice_pkts_rx_s = format_args!("{:.1}", voice_rates.packets_received_per_sec),
voice_pkts_tx_s = format_args!("{:.1}", voice_rates.packets_sent_per_sec),
"App metrics"
);
prev_http = current_http;
prev_udp = current_udp;
prev_voice = current_voice;
}
});
}
+3 -2
View File
@@ -56,9 +56,10 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
// Task pour recevoir les messages du WebSocket
let client_clone = client.clone();
let state_clone = state.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().await {
client_clone.on_message(message).await;
client_clone.on_message(message, &state_clone).await;
}
});
@@ -70,5 +71,5 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
state.gateway.remove_client(&client);
// // Déconnexion (Disconnect)
client.on_disconnect().await;
client.on_disconnect(&state).await;
}
+7 -4
View File
@@ -1,3 +1,4 @@
use crate::core::AppState;
use crate::domain::events::channel::{
ChannelCreatedEvent, ChannelDeletedEvent, ChannelUpdatedEvent,
};
@@ -472,16 +473,18 @@ impl GatewayClient {
}
}
async fn on_connect(&mut self) {
pub async fn on_connect(&mut self) {
tracing::info!(user_id = %self.user.id, "Client connected");
}
async fn on_disconnect(&mut self) {
pub async fn on_disconnect(&mut self, state: &AppState) {
tracing::info!(user_id = %self.user.id, "Client disconnected");
state.voice.leave_all(self.user.id).await;
}
async fn on_message(&self, message: Message) {
pub async fn on_message(&self, message: Message, _state: &AppState) {
if let Message::Text(content) = message {
tracing::info!(user_id = %self.user.id, "Received text message: {}", content);
tracing::debug!(user_id = %self.user.id, "Received text message: {}", content);
}
}
}
+4 -1
View File
@@ -18,6 +18,7 @@ pub mod role;
pub mod server;
pub mod server_item_order;
pub mod user;
pub mod voice;
pub fn router() -> OxRouter {
// Routes nécessitant une authentification
@@ -41,7 +42,9 @@ pub fn router() -> OxRouter {
.merge(core::routes::router());
let public_attachment_routes = attachment::routes::public_router();
let ws_routes = Router::new().merge(gateway::routes::router());
let ws_routes = Router::new()
.merge(gateway::routes::router())
.merge(voice::routes::router());
Router::new()
.nest("/api", api_routes)
+94
View File
@@ -0,0 +1,94 @@
use super::messages::{VoiceClientMessage, VoiceServerMessage};
use crate::{core::AppState, http::context::CurrentUser};
use axum::{
extract::{
State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state, user))
}
async fn handle_socket(socket: WebSocket, state: AppState, user: crate::models::user::Model) {
let (mut sender, mut receiver) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
let send_task = tokio::spawn(async move {
while let Some(message) = rx.recv().await {
if sender.send(message).await.is_err() {
break;
}
}
});
while let Some(Ok(message)) = receiver.next().await {
let Message::Text(text) = message else {
continue;
};
let parsed = match serde_json::from_str::<VoiceClientMessage>(&text) {
Ok(message) => message,
Err(error) => {
send_error(&tx, format!("Invalid voice message: {error}"));
continue;
}
};
match parsed {
VoiceClientMessage::Offer { channel_id, sdp } => {
match state
.voice
.handle_offer(
user.id,
channel_id,
sdp,
&state.repositories,
Some(tx.clone()),
)
.await
{
Ok(answer) => send(
&tx,
VoiceServerMessage::Answer {
channel_id,
sdp: answer,
},
),
Err(error) => send_error(&tx, error.to_string()),
}
}
VoiceClientMessage::IceCandidate {
channel_id,
candidate,
} => {
if let Err(error) = state
.voice
.handle_ice_candidate(user.id, channel_id, candidate)
.await
{
send_error(&tx, error.to_string());
}
}
VoiceClientMessage::Leave { channel_id } => {
state.voice.leave(user.id, channel_id).await
}
}
}
state.voice.leave_all(user.id).await;
send_task.abort();
}
fn send(tx: &mpsc::UnboundedSender<Message>, message: VoiceServerMessage) {
if let Ok(json) = serde_json::to_string(&message) {
let _ = tx.send(Message::Text(json.into()));
}
}
fn send_error(tx: &mpsc::UnboundedSender<Message>, message: String) {
send(tx, VoiceServerMessage::Error { message });
}
+18
View File
@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "kebab-case")]
pub enum VoiceClientMessage {
Offer { channel_id: Uuid, sdp: String },
IceCandidate { channel_id: Uuid, candidate: String },
Leave { channel_id: Uuid },
}
#[derive(Debug, Serialize)]
#[serde(tag = "action", rename_all = "kebab-case")]
pub enum VoiceServerMessage {
Answer { channel_id: Uuid, sdp: String },
IceCandidate { channel_id: Uuid, candidate: String },
Error { message: String },
}
+3
View File
@@ -0,0 +1,3 @@
pub mod handlers;
pub mod messages;
pub mod routes;
+7
View File
@@ -0,0 +1,7 @@
use super::handlers;
use crate::core::AppState;
use axum::{Router, routing::get};
pub fn router() -> Router<AppState> {
Router::new().route("/voice", get(handlers::ws_handler))
}
-244
View File
@@ -1,244 +0,0 @@
//! Métrologie du serveur UDP.
//!
//! Ce module expose :
//! - [`UdpMetrics`] : compteurs atomiques lock-free (pas de contention dans la
//! boucle de routage).
//! - [`UdpMetricsSnapshot`] : lecture cohérente de tous les compteurs à un
//! instant T, utilisable pour calculer des deltas.
//! - [`UdpRates`] : taux moyens par seconde calculés entre deux snapshots.
//! - [`spawn_reporter`] : tâche tokio de reporting périodique via `tracing`.
//!
//! # Métriques collectées
//!
//! | Compteur | Description |
//! |--------------------|-----------------------------------------------|
//! | `packets_received` | Datagrammes reçus |
//! | `bytes_received` | Octets reçus (payload uniquement) |
//! | `packets_sent` | Datagrammes retransmis vers des abonnés |
//! | `bytes_sent` | Octets retransmis |
//! | `packets_dropped` | Paquets ignorés (canal sans abonnés) |
//! | `send_errors` | Échecs `send_to` |
//! | `recv_errors` | Échecs `recv_from` (avant erreur fatale) |
//!
//! Chaque métrique est également disponible en taux moyen par seconde via
//! [`UdpMetricsSnapshot::rates_since`].
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use crate::metrics::{Metrics, MetricsSnapshot};
// ── Compteurs ────────────────────────────────────────────────────────────────
/// Compteurs atomiques du serveur UDP.
///
/// Partagé via [`Arc`] entre la boucle de routage et le reporter périodique.
/// Tous les accès utilisent [`Ordering::Relaxed`] : on accepte que les lectures
/// voient des valeurs légèrement décalées entre compteurs (suffisant pour de la
/// métrologie), ce qui évite tout overhead de synchronisation.
#[derive(Debug, Default)]
pub struct UdpMetrics {
/// Nombre total de datagrammes reçus.
pub packets_received: AtomicU64,
/// Volume total d'octets reçus (payload des datagrammes).
pub bytes_received: AtomicU64,
/// Nombre total de datagrammes retransmis (somme sur tous les abonnés).
pub packets_sent: AtomicU64,
/// Volume total d'octets retransmis.
pub bytes_sent: AtomicU64,
/// Paquets ignorés car le canal ne possède aucun abonné.
pub packets_dropped: AtomicU64,
/// Nombre d'erreurs `send_to` (non fatales).
pub send_errors: AtomicU64,
/// Nombre d'erreurs `recv_from` enregistrées avant arrêt du serveur.
pub recv_errors: AtomicU64,
}
impl UdpMetrics {
/// Crée un jeu de métriques vide enroulé dans un [`Arc`].
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Enregistre la réception d'un datagramme de `bytes` octets.
#[inline]
pub fn inc_received(&self, bytes: u64) {
self.packets_received.fetch_add(1, Ordering::Relaxed);
self.bytes_received.fetch_add(bytes, Ordering::Relaxed);
}
/// Enregistre l'émission d'un datagramme de `bytes` octets vers un client.
#[inline]
pub fn inc_sent(&self, bytes: u64) {
self.packets_sent.fetch_add(1, Ordering::Relaxed);
self.bytes_sent.fetch_add(bytes, Ordering::Relaxed);
}
/// Enregistre un paquet ignoré (canal sans abonnés).
#[inline]
pub fn inc_dropped(&self) {
self.packets_dropped.fetch_add(1, Ordering::Relaxed);
}
/// Enregistre un échec `send_to` non fatal.
#[inline]
pub fn inc_send_error(&self) {
self.send_errors.fetch_add(1, Ordering::Relaxed);
}
/// Enregistre un échec `recv_from`.
#[inline]
pub fn inc_recv_error(&self) {
self.recv_errors.fetch_add(1, Ordering::Relaxed);
}
/// Prend un instantané cohérent de tous les compteurs.
pub fn snapshot(&self) -> UdpMetricsSnapshot {
UdpMetricsSnapshot {
taken_at: Instant::now(),
packets_received: self.packets_received.load(Ordering::Relaxed),
bytes_received: self.bytes_received.load(Ordering::Relaxed),
packets_sent: self.packets_sent.load(Ordering::Relaxed),
bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
packets_dropped: self.packets_dropped.load(Ordering::Relaxed),
send_errors: self.send_errors.load(Ordering::Relaxed),
recv_errors: self.recv_errors.load(Ordering::Relaxed),
}
}
}
impl Metrics for UdpMetrics {
type Snapshot = UdpMetricsSnapshot;
fn snapshot(&self) -> UdpMetricsSnapshot {
self.snapshot()
}
}
// ── Snapshot ─────────────────────────────────────────────────────────────────
/// Lecture cohérente de l'ensemble des compteurs à un instant T.
///
/// Permet de calculer des deltas et des taux entre deux points dans le temps
/// sans bloquer la boucle de routage.
#[derive(Debug, Clone, Copy)]
pub struct UdpMetricsSnapshot {
pub taken_at: Instant,
pub packets_received: u64,
pub bytes_received: u64,
pub packets_sent: u64,
pub bytes_sent: u64,
pub packets_dropped: u64,
pub send_errors: u64,
pub recv_errors: u64,
}
impl UdpMetricsSnapshot {
/// Calcule les taux moyens par seconde depuis un snapshot précédent.
pub fn rates_since(&self, previous: &Self) -> UdpRates {
let secs = self
.taken_at
.duration_since(previous.taken_at)
.as_secs_f64()
.max(f64::EPSILON);
UdpRates {
packets_received_per_sec: self
.packets_received
.saturating_sub(previous.packets_received)
as f64
/ secs,
bytes_received_per_sec: self.bytes_received.saturating_sub(previous.bytes_received)
as f64
/ secs,
packets_sent_per_sec: self.packets_sent.saturating_sub(previous.packets_sent) as f64
/ secs,
bytes_sent_per_sec: self.bytes_sent.saturating_sub(previous.bytes_sent) as f64 / secs,
packets_dropped_per_sec: self
.packets_dropped
.saturating_sub(previous.packets_dropped)
as f64
/ secs,
}
}
}
impl MetricsSnapshot for UdpMetricsSnapshot {
fn taken_at(&self) -> Instant {
self.taken_at
}
}
// ── Taux ─────────────────────────────────────────────────────────────────────
/// Taux moyens par seconde calculés entre deux [`UdpMetricsSnapshot`].
#[derive(Debug, Clone, Copy)]
pub struct UdpRates {
/// Paquets reçus par seconde.
pub packets_received_per_sec: f64,
/// Octets reçus par seconde.
pub bytes_received_per_sec: f64,
/// Paquets envoyés par seconde.
pub packets_sent_per_sec: f64,
/// Octets envoyés par seconde.
pub bytes_sent_per_sec: f64,
/// Paquets ignorés par seconde.
pub packets_dropped_per_sec: f64,
}
// ── Reporter périodique ───────────────────────────────────────────────────────
/// Lance une tâche Tokio qui logue les métriques toutes les `interval`.
///
/// Chaque rapport inclut les compteurs cumulatifs **et** les taux moyens sur
/// la fenêtre écoulée depuis le rapport précédent.
///
/// # Exemple
/// ```no_run
/// use std::time::Duration;
/// use std::sync::Arc;
/// use oxspeak_server_lib::udp::metrics::{UdpMetrics, spawn_reporter};
///
/// #[tokio::main]
/// async fn main() {
/// let metrics = UdpMetrics::new();
/// spawn_reporter(Arc::clone(&metrics), Duration::from_secs(5));
/// }
/// ```
pub fn spawn_reporter(metrics: Arc<UdpMetrics>, interval: Duration) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// Le premier tick est immédiat ; on le consomme pour démarrer à t=0.
ticker.tick().await;
let mut prev_snapshot = metrics.snapshot();
loop {
ticker.tick().await;
let current = metrics.snapshot();
let rates = current.rates_since(&prev_snapshot);
tracing::info!(
// ── Cumulatifs ──
pkts_rx = current.packets_received,
bytes_rx = current.bytes_received,
pkts_tx = current.packets_sent,
bytes_tx = current.bytes_sent,
pkts_dropped = current.packets_dropped,
send_errors = current.send_errors,
recv_errors = current.recv_errors,
// ── Taux / s ──
pkts_rx_s = format!("{:.1}", rates.packets_received_per_sec),
bytes_rx_s = format!("{:.0}", rates.bytes_received_per_sec),
pkts_tx_s = format!("{:.1}", rates.packets_sent_per_sec),
bytes_tx_s = format!("{:.0}", rates.bytes_sent_per_sec),
pkts_dropped_s = format!("{:.1}", rates.packets_dropped_per_sec),
"UDP metrics"
);
prev_snapshot = current;
}
});
}
-3
View File
@@ -1,3 +0,0 @@
pub mod metrics;
pub mod router;
pub mod server;
-61
View File
@@ -1,61 +0,0 @@
use std::collections::HashMap;
use std::net::SocketAddr;
/// Identifiant d'un canal de routage (ex: room ID, channel name…).
pub type ChannelId = String;
/// Table de routage UDP.
///
/// Associe un identifiant de canal à la liste des clients (adresses IP/port)
/// actuellement abonnés à ce canal. Le serveur UDP utilise cette table pour
/// décider où retransmettre les paquets entrants.
///
/// # Note future
/// Ce type est intentionnellement simple pour démarrer. La prochaine étape
/// sera d'y associer une logique de dispatch (ex: forwarding sélectif,
/// authentification du client, etc.).
#[derive(Debug, Default)]
pub struct RoutingTable {
channels: HashMap<ChannelId, Vec<SocketAddr>>,
}
impl RoutingTable {
/// Crée une table de routage vide.
pub fn new() -> Self {
Self::default()
}
/// Inscrit un client dans un canal.
///
/// Si le canal n'existe pas encore, il est créé automatiquement.
/// Si le client est déjà inscrit dans ce canal, l'appel est sans effet.
pub fn join(&mut self, channel: impl Into<ChannelId>, client: SocketAddr) {
let clients = self.channels.entry(channel.into()).or_default();
if !clients.contains(&client) {
clients.push(client);
}
}
/// Retire un client d'un canal.
///
/// Si le canal devient vide après le retrait, il est supprimé de la table.
pub fn leave(&mut self, channel: &str, client: &SocketAddr) {
if let Some(clients) = self.channels.get_mut(channel) {
clients.retain(|c| c != client);
if clients.is_empty() {
self.channels.remove(channel);
}
}
}
/// Retourne la liste des clients abonnés à un canal, ou `None` si le
/// canal n'existe pas.
pub fn subscribers(&self, channel: &str) -> Option<&[SocketAddr]> {
self.channels.get(channel).map(Vec::as_slice)
}
/// Retourne tous les canaux connus et leurs abonnés.
pub fn channels(&self) -> &HashMap<ChannelId, Vec<SocketAddr>> {
&self.channels
}
}
-178
View File
@@ -1,178 +0,0 @@
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::broadcast;
use super::metrics::UdpMetrics;
use super::router::RoutingTable;
use crate::config::NetworkConfig;
/// Taille du buffer de lecture pour chaque datagramme UDP entrant.
///
/// La RFC 768 limite les datagrammes UDP à 65 507 octets (payload max avec
/// en-têtes IP+UDP). Pour de la voix/vidéo compressée, les paquets réels
/// seront bien plus petits, mais on alloue le maximum une seule fois pour
/// éviter toute troncature silencieuse.
const UDP_READ_BUFFER_SIZE: usize = 65_507;
/// Erreurs pouvant survenir pendant l'opération du serveur UDP.
#[derive(Debug, thiserror::Error)]
pub enum UdpServerError {
#[error("failed to bind UDP socket to {addr}: {source}")]
Bind {
addr: SocketAddr,
#[source]
source: std::io::Error,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
/// Serveur UDP asynchrone agissant comme routeur de paquets.
///
/// Reçoit des datagrammes entrants et les route vers les clients inscrits
/// dans les canaux correspondants via une [`RoutingTable`].
///
/// La configuration réseau est fournie par [`NetworkConfig`] (issue de
/// [`AppConfig`][crate::config::AppConfig]), qui centralise la lecture du
/// fichier TOML.
///
/// Les métriques sont collectées dans un [`UdpMetrics`] partageable via
/// [`Arc`] — passez-le à [`metrics::spawn_reporter`][super::metrics::spawn_reporter]
/// pour un reporting périodique automatique.
///
/// # Exemple
/// ```no_run
/// use std::time::Duration;
/// use oxspeak_server_lib::config::{AppConfig, NetworkConfig};
/// use oxspeak_server_lib::udp::server::UdpServer;
/// use oxspeak_server_lib::udp::metrics::{UdpMetrics, spawn_reporter};
///
/// #[tokio::main]
/// async fn main() {
/// let config = AppConfig::load().unwrap();
/// let metrics = UdpMetrics::new();
/// spawn_reporter(metrics.clone(), Duration::from_secs(10));
/// let (server, _shutdown_tx) = UdpServer::new(config.network, metrics);
/// server.run().await.unwrap();
/// }
/// ```
pub struct UdpServer {
bind_addr: SocketAddr,
routing_table: RoutingTable,
metrics: Arc<UdpMetrics>,
shutdown_rx: broadcast::Receiver<()>,
}
impl UdpServer {
/// Crée un nouveau [`UdpServer`] depuis la configuration réseau globale.
///
/// Retourne le serveur et un [`broadcast::Sender`] pour déclencher le
/// shutdown gracieux.
pub fn new(network: &NetworkConfig, metrics: Arc<UdpMetrics>) -> (Self, broadcast::Sender<()>) {
let bind_addr = SocketAddr::new(network.host.into(), network.udp_port);
let (shutdown_tx, shutdown_rx) = broadcast::channel(1);
(
Self {
bind_addr,
routing_table: RoutingTable::new(),
metrics,
shutdown_rx,
},
shutdown_tx,
)
}
/// Expose la table de routage de façon mutable pour y inscrire des
/// clients avant ou pendant l'exécution (via partage d'état ou messages).
pub fn routing_table_mut(&mut self) -> &mut RoutingTable {
&mut self.routing_table
}
/// Retourne une référence aux métriques du serveur.
pub fn metrics(&self) -> &Arc<UdpMetrics> {
&self.metrics
}
/// Bind le socket et démarre la boucle de routage.
///
/// Pour chaque datagramme reçu, le paquet est retransmis inline (sans
/// spawn de tâche) vers tous les clients abonnés au canal identifié.
/// La future se résout lorsqu'un signal de shutdown est reçu ou qu'une
/// erreur I/O fatale survient.
pub async fn run(mut self) -> Result<(), UdpServerError> {
let socket =
UdpSocket::bind(self.bind_addr)
.await
.map_err(|source| UdpServerError::Bind {
addr: self.bind_addr,
source,
})?;
tracing::info!(addr = %self.bind_addr, "UDP server listening");
let mut buf = vec![0u8; UDP_READ_BUFFER_SIZE];
loop {
tokio::select! {
result = socket.recv_from(&mut buf) => {
match result {
Ok((len, peer)) => {
self.metrics.inc_received(len as u64);
self.route_packet(&socket, &buf[..len], peer).await;
}
Err(err) => {
self.metrics.inc_recv_error();
tracing::error!(%err, "recv_from failed");
return Err(UdpServerError::Io(err));
}
}
}
_ = self.shutdown_rx.recv() => {
tracing::info!("UDP server shutting down");
break;
}
}
}
Ok(())
}
/// Route un paquet entrant vers les abonnés du canal correspondant.
///
/// # Logique actuelle (placeholder)
/// En l'absence de protocole applicatif défini, tous les paquets reçus
/// sont logués. Branche ici la logique d'identification du canal
/// (ex: lire un header de paquet pour extraire le `channel_id`).
async fn route_packet(&self, socket: &UdpSocket, data: &[u8], sender: SocketAddr) {
tracing::debug!(%sender, bytes = data.len(), "datagram received");
// TODO: extraire le channel_id depuis le header du paquet applicatif.
// Pour l'instant on utilise un canal de démonstration statique.
let channel_id = "default";
match self.routing_table.subscribers(channel_id) {
Some(clients) => {
for &client in clients {
// Ne pas renvoyer au sender lui-même.
if client == sender {
continue;
}
match socket.send_to(data, client).await {
Ok(_) => {
self.metrics.inc_sent(data.len() as u64);
}
Err(err) => {
self.metrics.inc_send_error();
tracing::warn!(%client, %err, "failed to forward packet");
}
}
}
}
None => {
self.metrics.inc_dropped();
tracing::debug!(%sender, channel = channel_id, "no subscribers, packet dropped");
}
}
}
}
+178
View File
@@ -0,0 +1,178 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use crate::metrics::{Metrics, MetricsSnapshot};
/// Compteurs atomiques pour les métriques de la voix / WebRTC.
#[derive(Debug, Default)]
pub struct VoiceMetrics {
/// Nombre total de datagrammes / paquets reçus.
pub packets_received: AtomicU64,
/// Volume total d'octets reçus.
pub bytes_received: AtomicU64,
/// Nombre total de datagrammes / paquets retransmis.
pub packets_sent: AtomicU64,
/// Volume total d'octets retransmis.
pub bytes_sent: AtomicU64,
/// Paquets ignorés ou rejetés.
pub packets_dropped: AtomicU64,
/// Nombre d'erreurs d'émission.
pub send_errors: AtomicU64,
/// Nombre d'erreurs de réception.
pub recv_errors: AtomicU64,
}
impl VoiceMetrics {
/// Crée un jeu de métriques vide enveloppé dans un [`Arc`].
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Enregistre la réception d'un paquet de `bytes` octets.
#[inline]
pub fn inc_received(&self, bytes: u64) {
self.packets_received.fetch_add(1, Ordering::Relaxed);
self.bytes_received.fetch_add(bytes, Ordering::Relaxed);
}
/// Enregistre l'émission d'un paquet de `bytes` octets.
#[inline]
pub fn inc_sent(&self, bytes: u64) {
self.packets_sent.fetch_add(1, Ordering::Relaxed);
self.bytes_sent.fetch_add(bytes, Ordering::Relaxed);
}
/// Enregistre un paquet ignoré.
#[inline]
pub fn inc_dropped(&self) {
self.packets_dropped.fetch_add(1, Ordering::Relaxed);
}
/// Enregistre un échec d'émission non fatal.
#[inline]
pub fn inc_send_error(&self) {
self.send_errors.fetch_add(1, Ordering::Relaxed);
}
/// Enregistre un échec de réception.
#[inline]
pub fn inc_recv_error(&self) {
self.recv_errors.fetch_add(1, Ordering::Relaxed);
}
/// Prend un instantané cohérent de tous les compteurs.
pub fn snapshot(&self) -> VoiceMetricsSnapshot {
VoiceMetricsSnapshot {
taken_at: Instant::now(),
packets_received: self.packets_received.load(Ordering::Relaxed),
bytes_received: self.bytes_received.load(Ordering::Relaxed),
packets_sent: self.packets_sent.load(Ordering::Relaxed),
bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
packets_dropped: self.packets_dropped.load(Ordering::Relaxed),
send_errors: self.send_errors.load(Ordering::Relaxed),
recv_errors: self.recv_errors.load(Ordering::Relaxed),
}
}
}
impl Metrics for VoiceMetrics {
type Snapshot = VoiceMetricsSnapshot;
fn snapshot(&self) -> VoiceMetricsSnapshot {
self.snapshot()
}
}
/// Lecture cohérente de l'ensemble des compteurs à un instant T.
#[derive(Debug, Clone, Copy)]
pub struct VoiceMetricsSnapshot {
pub taken_at: Instant,
pub packets_received: u64,
pub bytes_received: u64,
pub packets_sent: u64,
pub bytes_sent: u64,
pub packets_dropped: u64,
pub send_errors: u64,
pub recv_errors: u64,
}
impl VoiceMetricsSnapshot {
/// Calcule les taux moyens par seconde depuis un snapshot précédent.
pub fn rates_since(&self, previous: &Self) -> VoiceRates {
let secs = self
.taken_at
.duration_since(previous.taken_at)
.as_secs_f64()
.max(f64::EPSILON);
VoiceRates {
packets_received_per_sec: self
.packets_received
.saturating_sub(previous.packets_received)
as f64
/ secs,
bytes_received_per_sec: self.bytes_received.saturating_sub(previous.bytes_received)
as f64
/ secs,
packets_sent_per_sec: self.packets_sent.saturating_sub(previous.packets_sent) as f64
/ secs,
bytes_sent_per_sec: self.bytes_sent.saturating_sub(previous.bytes_sent) as f64 / secs,
packets_dropped_per_sec: self
.packets_dropped
.saturating_sub(previous.packets_dropped)
as f64
/ secs,
}
}
}
impl MetricsSnapshot for VoiceMetricsSnapshot {
fn taken_at(&self) -> Instant {
self.taken_at
}
}
/// Taux moyens par seconde calculés entre deux [`VoiceMetricsSnapshot`].
#[derive(Debug, Clone, Copy)]
pub struct VoiceRates {
pub packets_received_per_sec: f64,
pub bytes_received_per_sec: f64,
pub packets_sent_per_sec: f64,
pub bytes_sent_per_sec: f64,
pub packets_dropped_per_sec: f64,
}
pub fn spawn_reporter(metrics: Arc<VoiceMetrics>, interval: Duration) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await;
let mut prev_snapshot = metrics.snapshot();
loop {
ticker.tick().await;
let current = metrics.snapshot();
let rates = current.rates_since(&prev_snapshot);
tracing::info!(
pkts_rx = current.packets_received,
bytes_rx = current.bytes_received,
pkts_tx = current.packets_sent,
bytes_tx = current.bytes_sent,
pkts_dropped = current.packets_dropped,
send_errors = current.send_errors,
recv_errors = current.recv_errors,
pkts_rx_s = format!("{:.1}", rates.packets_received_per_sec),
bytes_rx_s = format!("{:.0}", rates.bytes_received_per_sec),
pkts_tx_s = format!("{:.1}", rates.packets_sent_per_sec),
bytes_tx_s = format!("{:.0}", rates.bytes_sent_per_sec),
pkts_dropped_s = format!("{:.1}", rates.packets_dropped_per_sec),
"Voice metrics"
);
prev_snapshot = current;
}
});
}
+12
View File
@@ -0,0 +1,12 @@
pub mod metrics;
pub mod room;
pub mod service;
pub use metrics::VoiceMetrics;
pub use room::VoiceRoom;
pub use service::VoiceService;
/// Nom générique du moteur média. `VoiceService` reste exporté pour compatibilité
/// avec le code de démarrage existant, mais ce service est destiné à l'audio,
/// la vidéo et les autres pistes WebRTC.
pub type MediaService = VoiceService;
+27
View File
@@ -0,0 +1,27 @@
use rustrtc::media::track::MediaRelay;
use rustrtc::peer_connection::PeerConnection;
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
/// Représente une salle vocale correspondant à un canal vocal (`ChannelType::Voice`).
#[derive(Clone)]
pub struct VoiceRoom {
pub channel_id: Uuid,
pub peers: HashMap<Uuid, Arc<PeerConnection>>,
pub relays: HashMap<Uuid, Arc<MediaRelay>>,
}
impl VoiceRoom {
pub fn new(channel_id: Uuid) -> Self {
Self {
channel_id,
peers: HashMap::new(),
relays: HashMap::new(),
}
}
pub fn is_empty(&self) -> bool {
self.peers.is_empty()
}
}
+431
View File
@@ -0,0 +1,431 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use tokio::sync::{RwLock, mpsc};
use uuid::Uuid;
use axum::extract::ws::Message;
use rustrtc::config::{RtcConfiguration, RtcConfigurationBuilder};
use rustrtc::media::track::MediaRelay;
use rustrtc::peer_connection::{PeerConnection, PeerConnectionEvent, RtpCodecParameters};
use rustrtc::sdp::{SdpType, SessionDescription};
use rustrtc::transports::ice::IceCandidate;
use crate::config::NetworkConfig;
use crate::models::{channel::ChannelType, channel_user, computed_permission::PermissionScopeType};
use crate::permissions::ChannelPermission;
use crate::repositories::Repositories;
use crate::routes::voice::messages::VoiceServerMessage;
use crate::voice::metrics::VoiceMetrics;
use crate::voice::room::VoiceRoom;
/// Paramètres de codec audio par défaut (Opus).
pub fn opus_codec() -> RtpCodecParameters {
RtpCodecParameters {
payload_type: 111,
name: "opus".to_string(),
clock_rate: 48000,
channels: 2,
}
}
/// Moteur média WebRTC central (SFU).
///
/// Le WebSocket ne fait que transporter l'offre SDP, les réponses et les
/// candidats ICE. Toute la vie des PeerConnections et des pistes média est
/// gérée ici, ce qui permettra d'ajouter la vidéo sans mélanger les protocoles.
#[derive(Clone)]
pub struct VoiceService {
pub config: RtcConfiguration,
pub rooms: Arc<RwLock<HashMap<Uuid, VoiceRoom>>>,
pub metrics: Arc<VoiceMetrics>,
}
impl fmt::Debug for VoiceService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VoiceService")
.field("config", &self.config)
.field("metrics", &self.metrics)
.finish()
}
}
impl VoiceService {
/// Crée une nouvelle instance de [`VoiceService`] configurée avec les paramètres réseau.
pub fn new(network: &NetworkConfig, metrics: Arc<VoiceMetrics>) -> Self {
let builder = RtcConfigurationBuilder::new()
.ice_udp_mux(true)
.ice_udp_mux_port(network.udp_port)
.bind_ip(network.host.to_string());
let rtc_config = builder.build();
Self {
config: rtc_config,
rooms: Arc::new(RwLock::new(HashMap::new())),
metrics,
}
}
/// Récupère les métriques de la voix.
pub fn metrics(&self) -> &Arc<VoiceMetrics> {
&self.metrics
}
/// Vérifie si l'utilisateur possède la permission de rejoindre le canal vocal.
pub async fn check_permission(
repositories: &Repositories,
user_id: Uuid,
channel_id: Uuid,
) -> anyhow::Result<bool> {
let channel = repositories
.channel
.get_by_id(channel_id)
.await?
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
if channel.channel_type == ChannelType::DM {
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
let is_member = channel_user::Entity::find()
.filter(channel_user::Column::ChannelId.eq(channel_id))
.filter(channel_user::Column::UserId.eq(user_id))
.one(&repositories.channel.context.db)
.await?
.is_some();
return Ok(is_member);
}
if channel.channel_type != ChannelType::Voice {
return Ok(false);
}
let permissions = repositories.computed_permission.get_all().await?;
for perm in permissions {
if perm.user_id == user_id
&& perm.scope_type == PermissionScopeType::Channel
&& perm.resource_id == channel_id
{
let chan_perm = ChannelPermission::from_bits_retain(perm.permissions as u64);
if chan_perm.contains(ChannelPermission::JOIN_VOICE) {
return Ok(true);
}
}
}
Ok(false)
}
/// Traite une offre SDP entrante d'un client pour un canal donné.
pub async fn handle_offer(
&self,
user_id: Uuid,
channel_id: Uuid,
offer_sdp: String,
repositories: &Repositories,
ice_sender: Option<mpsc::UnboundedSender<Message>>,
) -> anyhow::Result<String> {
let allowed = Self::check_permission(repositories, user_id, channel_id).await?;
if !allowed {
anyhow::bail!("Permission denied: cannot join voice channel");
}
let pc = Arc::new(PeerConnection::new(self.config.clone()));
// Abonner la nouvelle PeerConnection à tous les flux déjà présents dans la room
let existing_relays = {
let rooms = self.rooms.read().await;
rooms
.get(&channel_id)
.map(|r| r.relays.clone())
.unwrap_or_default()
};
for (&other_user_id, relay) in &existing_relays {
if other_user_id != user_id {
let sub_track = relay.subscribe();
if let Err(err) = pc.add_track(sub_track, opus_codec()) {
tracing::warn!(%err, "Failed to subscribe new peer to existing track");
}
}
}
let offer_desc = SessionDescription::parse(SdpType::Offer, &offer_sdp)?;
pc.set_remote_description(offer_desc).await?;
let answer_desc = pc.create_answer().await?;
let answer_sdp = answer_desc.to_sdp_string();
pc.set_local_description(answer_desc)?;
// Enregistrer la peer connection dans la room
{
let mut rooms = self.rooms.write().await;
let room = rooms
.entry(channel_id)
.or_insert_with(|| VoiceRoom::new(channel_id));
if let Some(old_pc) = room.peers.insert(user_id, Arc::clone(&pc)) {
old_pc.close();
}
}
// Relayer les candidats ICE locaux vers le client via le WebSocket
if let Some(sender) = ice_sender {
let mut ice_rx = pc.subscribe_ice_candidates();
tokio::spawn(async move {
while let Ok(candidate) = ice_rx.recv().await {
let cand_sdp = candidate.to_sdp();
let event = VoiceServerMessage::IceCandidate {
channel_id,
candidate: cand_sdp,
};
if let Ok(json) = serde_json::to_string(&event) {
if sender.send(Message::Text(json.into())).is_err() {
break;
}
}
}
});
}
self.start_peer_event_loop(pc, user_id, channel_id);
self.metrics.inc_received(1);
Ok(answer_sdp)
}
/// Starts the media event loop for one WebRTC connection.
///
/// ```text
/// Client microphone/camera
/// |
/// | SRTP/RTP packets over ICE/UDP
/// v
/// rustrtc internal transport <- raw packets are read here
/// |
/// | PeerConnectionEvent::Track
/// v
/// this function (the server's media entry point)
/// |
/// | MediaRelay::subscribe() for every other peer
/// v
/// other PeerConnections -> their clients
/// ```
///
/// There is deliberately no `UdpSocket::recv` in this service. `rustrtc`
/// owns the UDP mux, ICE, SRTP decryption and RTP parsing. `recv()` below
/// receives the resulting high-level WebRTC events, especially tracks.
fn start_peer_event_loop(&self, pc: Arc<PeerConnection>, user_id: Uuid, channel_id: Uuid) {
let rooms = Arc::clone(&self.rooms);
let metrics = Arc::clone(&self.metrics);
tokio::spawn(async move {
while let Some(event) = pc.recv().await {
match event {
PeerConnectionEvent::Track(transceiver) => {
Self::forward_incoming_track(
rooms.as_ref(),
metrics.as_ref(),
user_id,
channel_id,
transceiver,
)
.await;
}
PeerConnectionEvent::DataChannel(_) => {}
}
}
});
}
/// Converts one received track into a relay and subscribes every other
/// peer in the same room to it. This is the SFU fan-out operation.
async fn forward_incoming_track(
rooms: &RwLock<HashMap<Uuid, VoiceRoom>>,
metrics: &VoiceMetrics,
user_id: Uuid,
channel_id: Uuid,
transceiver: Arc<rustrtc::peer_connection::RtpTransceiver>,
) {
let Some(receiver) = transceiver.receiver() else { return };
let relay = Arc::new(MediaRelay::new(receiver.track()));
let mut rooms = rooms.write().await;
let Some(room) = rooms.get_mut(&channel_id) else { return };
room.relays.insert(user_id, Arc::clone(&relay));
for (&other_user_id, other_pc) in &room.peers {
if other_user_id == user_id { continue; }
if let Err(error) = other_pc.add_track(relay.subscribe(), opus_codec()) {
tracing::warn!(%error, "Failed to fan-out incoming media track");
metrics.inc_send_error();
} else {
metrics.inc_sent(1);
}
}
}
/// Traite un candidat ICE reçu d'un client.
pub async fn handle_ice_candidate(
&self,
user_id: Uuid,
channel_id: Uuid,
candidate_sdp: String,
) -> anyhow::Result<()> {
let pc = {
let rooms = self.rooms.read().await;
rooms
.get(&channel_id)
.and_then(|r| r.peers.get(&user_id))
.cloned()
};
if let Some(pc) = pc {
let candidate = IceCandidate::from_sdp(&candidate_sdp)?;
pc.add_ice_candidate(candidate)?;
self.metrics.inc_received(1);
}
Ok(())
}
/// Quitte un canal vocal pour un utilisateur.
pub async fn leave(&self, user_id: Uuid, channel_id: Uuid) {
let pc_to_close = {
let mut rooms = self.rooms.write().await;
if let Some(room) = rooms.get_mut(&channel_id) {
let pc = room.peers.remove(&user_id);
room.relays.remove(&user_id);
if room.is_empty() {
rooms.remove(&channel_id);
}
pc
} else {
None
}
};
if let Some(pc) = pc_to_close {
pc.close();
}
}
/// Quitte tous les canaux vocaux pour un utilisateur (ex. déconnexion WebSocket).
pub async fn leave_all(&self, user_id: Uuid) {
let mut pcs_to_close = Vec::new();
{
let mut rooms = self.rooms.write().await;
let mut empty_rooms = Vec::new();
for (&channel_id, room) in rooms.iter_mut() {
if let Some(pc) = room.peers.remove(&user_id) {
pcs_to_close.push(pc);
room.relays.remove(&user_id);
}
if room.is_empty() {
empty_rooms.push(channel_id);
}
}
for channel_id in empty_rooms {
rooms.remove(&channel_id);
}
}
for pc in pcs_to_close {
pc.close();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
fn test_network_config() -> NetworkConfig {
NetworkConfig {
host: Ipv4Addr::new(127, 0, 0, 1),
hostv6: None,
tcp_port: 8080,
udp_port: 9000,
}
}
#[tokio::test]
async fn test_voice_service_creation_and_room_lifecycle() {
let network = test_network_config();
let metrics = VoiceMetrics::new();
let service = VoiceService::new(&network, metrics);
let user1 = Uuid::new_v4();
let user2 = Uuid::new_v4();
let channel_id = Uuid::new_v4();
// Check room creation and empty initially
assert!(service.rooms.read().await.is_empty());
// Insert peer connections into room
let pc1 = Arc::new(PeerConnection::new(service.config.clone()));
let pc2 = Arc::new(PeerConnection::new(service.config.clone()));
{
let mut rooms = service.rooms.write().await;
let room = rooms
.entry(channel_id)
.or_insert_with(|| VoiceRoom::new(channel_id));
room.peers.insert(user1, Arc::clone(&pc1));
room.peers.insert(user2, Arc::clone(&pc2));
}
assert_eq!(service.rooms.read().await.len(), 1);
assert_eq!(
service
.rooms
.read()
.await
.get(&channel_id)
.unwrap()
.peers
.len(),
2
);
// User 1 leaves
service.leave(user1, channel_id).await;
assert_eq!(
service
.rooms
.read()
.await
.get(&channel_id)
.unwrap()
.peers
.len(),
1
);
// User 2 leaves via leave_all
service.leave_all(user2).await;
assert!(service.rooms.read().await.is_empty());
}
#[test]
fn test_opus_codec_parameters() {
let codec = opus_codec();
assert_eq!(codec.name, "opus");
assert_eq!(codec.clock_rate, 48000);
assert_eq!(codec.channels, 2);
}
#[test]
fn test_voice_metrics_counters_and_rates() {
let metrics = VoiceMetrics::new();
metrics.inc_received(500);
metrics.inc_sent(1000);
metrics.inc_dropped();
metrics.inc_send_error();
metrics.inc_recv_error();
let snap = metrics.snapshot();
assert_eq!(snap.packets_received, 1);
assert_eq!(snap.bytes_received, 500);
assert_eq!(snap.packets_sent, 1);
assert_eq!(snap.bytes_sent, 1000);
assert_eq!(snap.packets_dropped, 1);
assert_eq!(snap.send_errors, 1);
assert_eq!(snap.recv_errors, 1);
}
}