Skip to main content

opentalk_roomserver_web_api/
livekit_proxy.rs

1// SPDX-License-Identifier: EUPL-1.2
2// SPDX-FileCopyrightText: OpenTalk Team <mail@opentalk.eu>
3
4use std::str::FromStr;
5
6use async_trait::async_trait;
7use axum::{
8    extract::{OriginalUri, Query, RawQuery, State, WebSocketUpgrade},
9    http::{HeaderMap, header::AUTHORIZATION},
10    response::Response,
11    routing::get,
12};
13use livekit_api::access_token::Claims;
14pub use opentalk_roomserver_types::livekit_proxy::{
15    LiveKitProxyRequest, LiveKitProxyTarget, PreparedSocket,
16};
17use opentalk_roomserver_types::{
18    LIVEKIT_SUBROOM_AUDIO_ROOM_DELIMITER,
19    breakout::breakout_id::BreakoutId,
20    connection_id::ConnectionId,
21    livekit_proxy::{axum::LiveKitSocketAdapter, websocket::LiveKitSocket},
22    room_kind::RoomKind,
23};
24use opentalk_types_api_internal::error::ApiError;
25use opentalk_types_common::rooms::RoomId;
26use opentalk_types_signaling::ParticipantId;
27use serde::Deserialize;
28use uuid::Uuid;
29
30use crate::{Router, v1::signaling::SignalingBackend};
31
32pub fn routes<B: LiveKitProxyBackend + SignalingBackend + 'static>() -> Router<B> {
33    Router::new().nest(
34        "/livekit/rtc",
35        Router::new()
36            .route("/", get(proxy_socket::<B>))
37            .route("/v1", get(proxy_socket::<B>))
38            .route("/validate", get(proxy_validate::<B>))
39            .route("/v1/validate", get(proxy_validate::<B>)),
40    )
41}
42
43/// A backend for proxying websocket requests between a frontend client and a LiveKit server.
44#[async_trait]
45pub trait LiveKitProxyBackend: Send + Sync {
46    /// Connect a websocket to the LiveKit server.
47    async fn connect_upstream_socket(
48        &self,
49        ws_request: LiveKitProxyRequest,
50    ) -> Result<PreparedSocket, ApiError>;
51
52    /// Connect a websocket to the frontend client.
53    async fn connect_downstream_socket(
54        &self,
55        ws_request: LiveKitProxyRequest,
56        upstream_socket: PreparedSocket,
57        socket: Box<dyn LiveKitSocket>,
58    ) -> Result<(), ApiError>;
59
60    /// Proxies a LiveKit REST validation request to the livekit module
61    async fn proxy_livekit_validate(
62        &self,
63        room_id: RoomId,
64        headers: HeaderMap,
65        raw_query: Option<String>,
66        v1: bool,
67    ) -> Result<reqwest::Response, ApiError>;
68}
69
70#[derive(Debug, Deserialize)]
71pub struct LiveKitQuery {
72    pub access_token: Option<String>,
73}
74
75/// Opens a new LiveKit WebSocket connection for RTC communication.
76///
77/// This endpoint establishes a WebSocket connection to the upstream LiveKit server.
78/// The access token can be provided either as a query parameter or in the `Authorization`
79/// header with the `Bearer` scheme.
80///
81/// # Available paths
82/// - `/livekit/rtc`
83/// - `/livekit/rtc/v1`
84#[utoipa::path(
85    get,
86    path = "/livekit/rtc",
87    responses(
88        (status = StatusCode::SWITCHING_PROTOCOLS, description = "Successfully upgraded connection to WebSocket for RTC communication"),
89        (status = StatusCode::BAD_REQUEST, description = "Invalid token format or missing required token claims"),
90        (status = StatusCode::UNAUTHORIZED, description = "Missing access token or invalid token scheme"),
91        (status = StatusCode::INTERNAL_SERVER_ERROR, description = "An internal server error occurred"),
92    ),
93    params(
94        ("access_token" = Option<String>, Query, description = "LiveKit access token (JWT) as query parameter. If not provided, must be in Authorization header with Bearer scheme")
95    ),
96    security(
97        ("Livekit-Token" = [])
98    ),
99)]
100#[tracing::instrument(level = "info", name = "/livekit/proxy/rtc", skip_all)]
101pub(crate) async fn proxy_socket<B: LiveKitProxyBackend + 'static>(
102    State(ctx): State<B>,
103    RawQuery(raw_query): RawQuery,
104    Query(query): Query<LiveKitQuery>,
105    ws_upgrade: WebSocketUpgrade,
106    headers: HeaderMap,
107) -> Result<Response, ApiError> {
108    let access_token = extract_access_token(query, &headers)?;
109
110    // we do not verify the token since this is done by livekit. We only proxy the connection.
111    let content = jsonwebtoken::dangerous::insecure_decode::<Claims>(access_token.as_bytes())
112        .map_err(|err| {
113            tracing::debug!("Failed to decode livekit token: {err}");
114            ApiError::bad_request()
115        })?;
116    let (room_id, proxy_target) = parse_livekit_room_id(&content.claims.video.room)?;
117    let (participant_id, connection_id) = parse_livekit_participant(&content.claims.sub)?;
118
119    let websocket_request = LiveKitProxyRequest {
120        raw_query,
121        headers,
122        room_id,
123        proxy_target,
124        participant_id,
125        connection_id,
126    };
127
128    let upstream_socket = ctx
129        .connect_upstream_socket(websocket_request.clone())
130        .await?;
131
132    Ok(ws_upgrade.on_upgrade(move |websocket| async move {
133        let socket = LiveKitSocketAdapter::new(websocket);
134        if let Err(err) = ctx
135            .connect_downstream_socket(websocket_request, upstream_socket, Box::new(socket))
136            .await
137        {
138            tracing::warn!("failed to accept livekit websocket: {err:?}");
139        }
140    }))
141}
142
143/// Proxies the LiveKit validate request to the upstream livekit service via the room task
144///
145/// # Available paths
146/// - `/livekit/rtc/validate`
147/// - `/livekit/rtc/v1/validate`
148#[utoipa::path(
149    get,
150    path = "/livekit/rtc/validate",
151    responses(
152        (status = StatusCode::OK, description = "Validation response from LiveKit"),
153        (status = StatusCode::UNPROCESSABLE_ENTITY, description = "No livekit module configured for the request"),
154        (status = StatusCode::BAD_REQUEST, description = "Invalid request headers"),
155        (status = StatusCode::INTERNAL_SERVER_ERROR, description = "An internal server error occurred"),
156    ),
157    security(
158        ("Livekit-Token" = [])
159    ),
160)]
161#[tracing::instrument(level = "info", name = "/livekit/rtc/validate", skip_all)]
162pub(crate) async fn proxy_validate<B: LiveKitProxyBackend>(
163    State(ctx): State<B>,
164    OriginalUri(uri): OriginalUri,
165    RawQuery(raw_query): RawQuery,
166    Query(query): Query<LiveKitQuery>,
167    headers: HeaderMap,
168) -> Result<Response, ApiError> {
169    let access_token = extract_access_token(query, &headers)?;
170    let content = jsonwebtoken::dangerous::insecure_decode::<Claims>(access_token.as_bytes())
171        .map_err(|err| {
172            tracing::debug!("Failed to decode livekit token: {err}");
173            ApiError::bad_request()
174        })?;
175
176    let (room_id, _) = parse_livekit_room_id(&content.claims.video.room)?;
177
178    let v1 = uri.path().ends_with("v1/validate");
179    let response = ctx
180        .proxy_livekit_validate(room_id, headers, raw_query, v1)
181        .await?;
182
183    tracing::trace!("Received validate response: {response:?}");
184
185    let status = axum::http::StatusCode::from_u16(response.status().as_u16()).map_err(|err| {
186        tracing::error!("Failed to convert status code: {err}");
187        ApiError::internal()
188    })?;
189    let mut builder = axum::response::Response::builder().status(status);
190
191    for (name, value) in response.headers() {
192        builder = builder.header(name, value);
193    }
194
195    let body = response.bytes().await.map_err(|err| {
196        tracing::error!("Failed to convert response body: {err}");
197        ApiError::internal()
198    })?;
199
200    builder.body(axum::body::Body::from(body)).map_err(|err| {
201        tracing::error!("Failed to build response: {err}");
202        ApiError::internal()
203    })
204}
205
206pub fn parse_livekit_room_id(livekit_id: &str) -> Result<(RoomId, LiveKitProxyTarget), ApiError> {
207    if let Some((room_id, whisper_id)) = livekit_id.split_once(LIVEKIT_SUBROOM_AUDIO_ROOM_DELIMITER)
208    {
209        let room_id = RoomId::from_str(room_id).map_err(|err| {
210            tracing::debug!("Failed to parse room id {room_id:?}: {err}");
211            ApiError::bad_request()
212        })?;
213        let whisper_id = Uuid::from_str(whisper_id).map_err(|err| {
214            tracing::debug!("Failed to parse whisper id {whisper_id:?}: {err}");
215            ApiError::bad_request()
216        })?;
217
218        return Ok((room_id, LiveKitProxyTarget::SubroomAudio { whisper_id }));
219    }
220
221    let (room_id, room_kind) = livekit_id.split_once(':').ok_or_else(|| {
222        tracing::debug!("Failed to parse livekit room id, missing ':' delimiter in {livekit_id:?}");
223        ApiError::bad_request()
224    })?;
225
226    let room_id = RoomId::from_str(room_id).map_err(|err| {
227        tracing::debug!("Failed to parse room id {room_id:?}: {err}");
228        ApiError::bad_request()
229    })?;
230    let room_kind = if room_kind == "main" {
231        RoomKind::Main
232    } else {
233        RoomKind::Breakout(BreakoutId::from_str(room_kind).map_err(|err| {
234            tracing::debug!("Failed to parse breakout id {room_kind:?}: {err}");
235            ApiError::bad_request()
236        })?)
237    };
238
239    Ok((room_id, LiveKitProxyTarget::LiveKit { room_kind }))
240}
241
242pub fn parse_livekit_participant(
243    livekit_sub: &str,
244) -> Result<(ParticipantId, ConnectionId), ApiError> {
245    // Discard any popout stream suffix
246    let qualified_id = livekit_sub
247        .rsplit_once("-popout")
248        .unwrap_or((livekit_sub, ""))
249        .0;
250
251    let Some((participant, connection)) = qualified_id.split_once(':') else {
252        tracing::debug!(
253            "Failed to parse livekit participant, missing ':' delimiter in {livekit_sub:?}"
254        );
255        return Err(ApiError::bad_request());
256    };
257
258    let participant = participant.parse().map_err(|err| {
259        tracing::debug!("Failed to parse participant id {participant:?}: {err}");
260        ApiError::bad_request()
261    })?;
262    let connection = connection.parse().map_err(|err| {
263        tracing::debug!("Failed to parse connection id {connection:?}: {err}");
264        ApiError::bad_request()
265    })?;
266
267    Ok((participant, connection))
268}
269
270fn extract_access_token(query: LiveKitQuery, headers: &HeaderMap) -> Result<String, ApiError> {
271    if let Some(token) = query.access_token {
272        Ok(token)
273    } else {
274        let Some(access_token) = headers
275            .get(&AUTHORIZATION)
276            .and_then(|header_value| header_value.to_str().ok())
277            .and_then(|header_value| {
278                let (scheme, token) = header_value.split_once(' ')?;
279
280                if scheme.eq_ignore_ascii_case("bearer") {
281                    Some(token.to_string())
282                } else {
283                    None
284                }
285            })
286        else {
287            return Err(ApiError::unauthorized());
288        };
289
290        Ok(access_token)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use opentalk_roomserver_types::connection_id::ConnectionId;
297    use opentalk_types_signaling::ParticipantId;
298
299    use super::parse_livekit_participant;
300
301    #[test]
302    fn parse_livekit_participant_valid() {
303        let participant = ParticipantId::from_u128(0x1234);
304        let connection = ConnectionId::from_u128(0x5678);
305        let livekit_sub = format!("{participant}:{connection}");
306
307        let parsed = parse_livekit_participant(&livekit_sub);
308
309        assert_eq!(parsed.unwrap(), (participant, connection));
310    }
311
312    #[test]
313    fn parse_livekit_participant_valid_popout() {
314        let participant = ParticipantId::from_u128(0x1234);
315        let connection = ConnectionId::from_u128(0x5678);
316        let livekit_sub = format!("{participant}:{connection}-popout1");
317
318        let parsed = parse_livekit_participant(&livekit_sub);
319
320        assert_eq!(parsed.unwrap(), (participant, connection));
321    }
322
323    #[test]
324    fn parse_livekit_participant_missing_delimiter() {
325        let err = parse_livekit_participant("not-a-valid-sub");
326
327        assert!(err.is_err());
328    }
329
330    #[test]
331    fn parse_livekit_participant_invalid_participant_id() {
332        let connection = ConnectionId::from_u128(0x5678);
333        let livekit_sub = format!("invalid:{connection}");
334
335        let err = parse_livekit_participant(&livekit_sub);
336
337        assert!(err.is_err());
338    }
339
340    #[test]
341    fn parse_livekit_participant_invalid_connection_id() {
342        let participant = ParticipantId::from_u128(0x1234);
343        let livekit_sub = format!("{participant}:invalid");
344
345        let err = parse_livekit_participant(&livekit_sub);
346
347        assert!(err.is_err());
348    }
349}