Skip to main content

o_sfu/runtime/http_server/
controller.rs

1use std::{io, net::SocketAddr, sync::Arc};
2
3use axum::{
4    Router,
5    extract::{DefaultBodyLimit, MatchedPath, Path, Request, State},
6    http::{StatusCode, header},
7    middleware,
8    response::{IntoResponse, Response},
9    routing::{get, post},
10};
11use o_sfu_core::server::room::RoomManagerServeError;
12use o_sfu_protocol::wire::StreamType;
13use tokio::net::TcpListener;
14use tokio_util::sync::CancellationToken;
15use tracing::{Instrument, info};
16
17use crate::{
18    application::stream_catalog::counter_for_stream_type,
19    runtime::{
20        RuntimeState, diagnostics,
21        http_server::{
22            contract::{
23                IncomingBitRateStatsResponse, NoopResponse, RoomResponse, RoomStatsResponse,
24                UsersStatsResponse, route,
25            },
26            extractors::{
27                DiagnosticsServices, MetricsServices, OperatorAccess, OperatorAccessPolicy,
28                RoomServices, VerifiedDisconnectClaims, VerifiedRoomRequest,
29            },
30        },
31        metrics::{HttpRoute, RuntimeMetrics},
32        prometheus::{PROMETHEUS_CONTENT_TYPE, render_prometheus},
33        room::RuntimeRoomStatsSnapshot,
34        telemetry::{self, schema::event as telemetry_event},
35        websocket_server,
36    },
37};
38
39const MAX_DISCONNECT_BODY_BYTES: usize = 16 * 1024;
40
41pub(crate) async fn serve_http(
42    state: RuntimeState,
43    shutdown_token: CancellationToken,
44) -> io::Result<()> {
45    let listener = TcpListener::bind(state.config.http.bind_address).await?;
46    serve_http_on(listener, state, shutdown_token).await
47}
48
49pub(crate) async fn serve_http_on(
50    listener: TcpListener,
51    state: RuntimeState,
52    shutdown_token: CancellationToken,
53) -> io::Result<()> {
54    let local_address = listener.local_addr()?;
55    info!(
56        event = telemetry_event::HTTP_LISTENER_READY,
57        bind_address = %state.config.http.bind_address,
58        local_address = %local_address,
59        trust_proxy_headers = state.config.http.trust_proxy_headers,
60        "booted HTTP and WebSocket listener"
61    );
62    axum::serve(
63        listener,
64        app(state, local_address).into_make_service_with_connect_info::<SocketAddr>(),
65    )
66    .with_graceful_shutdown(shutdown_token.cancelled_owned())
67    .await
68}
69
70/// builds the Axum router for the HTTP control plane and WebSocket listener
71pub(crate) fn app(state: RuntimeState, listener_address: SocketAddr) -> Router {
72    let metrics = Arc::clone(&state.metrics);
73    let operator_policy = OperatorAccessPolicy::new(
74        state.config.diagnostics.auth_token.as_deref(),
75        listener_address,
76    );
77    Router::new()
78        .route(route::WEBSOCKET, get(websocket_server::upgrade))
79        .merge(http_router(metrics, operator_policy.clone()))
80        .merge(diagnostics_router(operator_policy))
81        .with_state(state)
82}
83
84fn http_router(
85    runtime_metrics: Arc<RuntimeMetrics>,
86    operator_policy: OperatorAccessPolicy,
87) -> Router<RuntimeState> {
88    let operator_layer =
89        middleware::from_extractor_with_state::<OperatorAccess, _>(operator_policy);
90    Router::new()
91        .route(route::v1::NOOP, get(noop))
92        .route(
93            route::v1::STATS,
94            get(stats).route_layer(operator_layer.clone()),
95        )
96        .route(route::v1::CHANNEL, get(room))
97        .route(
98            route::v1::DISCONNECT,
99            post(disconnect).layer(DefaultBodyLimit::max(MAX_DISCONNECT_BODY_BYTES)),
100        )
101        .route(route::METRICS, get(metrics).route_layer(operator_layer))
102        .route_layer(middleware::from_fn_with_state(
103            runtime_metrics,
104            track_http_request,
105        ))
106}
107
108async fn track_http_request(
109    State(metrics): State<Arc<RuntimeMetrics>>,
110    path: MatchedPath,
111    request: Request,
112    next: middleware::Next,
113) -> Response {
114    let route = match path.as_str() {
115        route::v1::NOOP => HttpRoute::Noop,
116        route::v1::STATS => HttpRoute::Stats,
117        route::v1::CHANNEL => HttpRoute::Room,
118        route::v1::DISCONNECT => HttpRoute::Disconnect,
119        route::METRICS => HttpRoute::Metrics,
120        _ => return next.run(request).await,
121    };
122    let _guard = metrics.track_http_request(route);
123    next.run(request).await
124}
125
126fn diagnostics_router(operator_policy: OperatorAccessPolicy) -> Router<RuntimeState> {
127    Router::new()
128        .route(route::diagnostics::SUMMARY, get(diagnostics_summary))
129        .route(route::diagnostics::ROOMS, get(diagnostics_rooms))
130        .route(route::diagnostics::WORKERS, get(diagnostics_workers))
131        .route(route::diagnostics::ROOM, get(diagnostics_room_detail))
132        .route(route::diagnostics::ROOM_USERS, get(diagnostics_room_users))
133        .route(route::diagnostics::ROOM_USER, get(diagnostics_user_detail))
134        .route(route::diagnostics::ROOM_GRAPH, get(diagnostics_room_graph))
135        .route(route::diagnostics::USER_GRAPH, get(diagnostics_user_graph))
136        .route_layer(middleware::from_extractor_with_state::<OperatorAccess, _>(
137            operator_policy,
138        ))
139}
140
141/// liveness endpoint for a cheap control-plane round trip
142async fn noop() -> impl IntoResponse {
143    async { axum::Json(NoopResponse::ok()) }
144        .instrument(telemetry::http_request_span("noop"))
145        .await
146}
147
148/// compatibility room-stat endpoint consumed by Odoo's SFU control plane
149async fn stats(State(services): State<RoomServices>) -> impl IntoResponse {
150    async {
151        axum::Json(
152            services
153                .room_manager
154                .stats_snapshots(&services.media_transport)
155                .await
156                .into_iter()
157                .map(http_room_stats)
158                .collect::<Vec<_>>(),
159        )
160    }
161    .instrument(telemetry::http_request_span("stats"))
162    .await
163}
164
165/// prometheus scrape endpoint for process, room, HTTP and media-transport metrics
166async fn metrics(State(services): State<MetricsServices>) -> impl IntoResponse {
167    async {
168        let room_gauges = services.room_manager.room_gauges().await;
169        (
170            [(header::CONTENT_TYPE, PROMETHEUS_CONTENT_TYPE)],
171            render_prometheus(&services.metrics, room_gauges),
172        )
173    }
174    .instrument(telemetry::http_request_span("metrics"))
175    .await
176}
177
178/// room creation endpoint used by Odoo to bind a channel key to an SFU room
179///
180/// `VerifiedRoomRequest` owns JWT verification and request-origin projection
181async fn room(State(services): State<RoomServices>, request: VerifiedRoomRequest) -> Response {
182    async {
183        let serve_result = services
184            .room_manager
185            .serve_room(
186                &request.issuer,
187                &request.room_key,
188                &request.config,
189                Some(request.origin.remote_address.as_str()),
190            )
191            .await;
192        match serve_result {
193            Ok(room) => {
194                services.metrics.record_http_room_success();
195                (
196                    StatusCode::OK,
197                    axum::Json(RoomResponse {
198                        uuid: room.uuid().to_owned(),
199                        url: request.origin.base_url,
200                    }),
201                )
202                    .into_response()
203            }
204            Err(RoomManagerServeError::ConflictingReservation) => {
205                services.metrics.record_http_room_conflict();
206                StatusCode::CONFLICT.into_response()
207            }
208        }
209    }
210    .instrument(telemetry::http_request_span("room"))
211    .await
212}
213
214/// bulk-disconnect endpoint used by Odoo to remove users from active rooms
215///
216/// `VerifiedDisconnectClaims` owns JWT verification and request-body decoding
217async fn disconnect(
218    State(services): State<RoomServices>,
219    VerifiedDisconnectClaims(claims): VerifiedDisconnectClaims,
220) -> Response {
221    async {
222        for (room_id, user_ids) in &claims.user_ids_by_room {
223            services
224                .room_manager
225                .disconnect_users(room_id, user_ids, &services.media_transport)
226                .await;
227        }
228        services.metrics.record_http_disconnect_success();
229        StatusCode::OK.into_response()
230    }
231    .instrument(telemetry::http_request_span("disconnect"))
232    .await
233}
234
235/// diagnostics overview for room, user and publication totals
236async fn diagnostics_summary(State(services): State<DiagnosticsServices>) -> Response {
237    axum::Json(
238        diagnostics::summary_response(&services.room_manager, &services.media_transport).await,
239    )
240    .into_response()
241}
242
243/// diagnostics inventory for active rooms
244async fn diagnostics_rooms(State(services): State<DiagnosticsServices>) -> Response {
245    axum::Json(diagnostics::rooms_response(&services.room_manager, &services.media_transport).await)
246        .into_response()
247}
248
249/// diagnostics inventory for media workers and load pressure
250async fn diagnostics_workers(State(services): State<DiagnosticsServices>) -> Response {
251    axum::Json(
252        diagnostics::workers_response(&services.room_manager, &services.media_transport).await,
253    )
254    .into_response()
255}
256
257/// live room diagnostics with users and sources
258async fn diagnostics_room_detail(
259    State(services): State<DiagnosticsServices>,
260    Path(room_id): Path<String>,
261) -> Response {
262    let payload = diagnostics::room_detail_response(
263        &services.room_manager,
264        &services.media_transport,
265        &room_id,
266    )
267    .await;
268    diagnostics_optional_response(payload)
269}
270
271/// live user rows for one room
272async fn diagnostics_room_users(
273    State(services): State<DiagnosticsServices>,
274    Path(room_id): Path<String>,
275) -> Response {
276    let payload = diagnostics::room_users_response(
277        &services.room_manager,
278        &services.media_transport,
279        &room_id,
280    )
281    .await;
282    diagnostics_optional_response(payload)
283}
284
285/// node-graph projection for one room diagnostics payload
286async fn diagnostics_room_graph(
287    State(services): State<DiagnosticsServices>,
288    Path(room_id): Path<String>,
289) -> Response {
290    let payload = diagnostics::room_detail_response(
291        &services.room_manager,
292        &services.media_transport,
293        &room_id,
294    )
295    .await
296    .map(|payload| diagnostics::build_graph(&payload));
297    diagnostics_optional_response(payload)
298}
299
300/// node-graph projection rooted at one user in one room
301async fn diagnostics_user_graph(
302    State(services): State<DiagnosticsServices>,
303    Path((room_id, user_key)): Path<(String, String)>,
304) -> Response {
305    let payload = diagnostics::room_detail_response(
306        &services.room_manager,
307        &services.media_transport,
308        &room_id,
309    )
310    .await
311    .and_then(|payload| diagnostics::build_user_graph(&payload, &user_key));
312    diagnostics_optional_response(payload)
313}
314
315fn diagnostics_optional_response<T>(payload: Option<T>) -> Response
316where
317    axum::Json<T>: IntoResponse,
318{
319    payload.map_or_else(
320        || StatusCode::NOT_FOUND.into_response(),
321        |payload| axum::Json(payload).into_response(),
322    )
323}
324
325/// diagnostics for one user in one room
326async fn diagnostics_user_detail(
327    State(services): State<DiagnosticsServices>,
328    Path((room_id, user_key)): Path<(String, String)>,
329) -> Response {
330    diagnostics_optional_response(
331        diagnostics::user_detail_response(
332            &services.room_manager,
333            &services.media_transport,
334            &room_id,
335            &user_key,
336        )
337        .await,
338    )
339}
340
341fn http_room_stats(snapshot: RuntimeRoomStatsSnapshot) -> RoomStatsResponse {
342    let incoming_bitrate = &snapshot.users_stats.incoming_bitrate;
343    let active_stream_counts = &snapshot.users_stats.active_stream_counts;
344    RoomStatsResponse {
345        create_date: snapshot.create_date,
346        uuid: snapshot.uuid,
347        remote_address: snapshot.remote_address,
348        users_stats: UsersStatsResponse {
349            incoming_bit_rate: IncomingBitRateStatsResponse {
350                total: incoming_bitrate.total,
351                audio: counter_for_stream_type(&incoming_bitrate.by_stream, StreamType::Audio),
352                camera: counter_for_stream_type(&incoming_bitrate.by_stream, StreamType::Camera),
353                screen: counter_for_stream_type(&incoming_bitrate.by_stream, StreamType::Screen),
354            },
355            count: snapshot.users_stats.count,
356            camera_count: counter_for_stream_type(active_stream_counts, StreamType::Camera),
357            screen_count: counter_for_stream_type(active_stream_counts, StreamType::Screen),
358        },
359        web_rtc_enabled: snapshot.web_rtc_enabled,
360    }
361}