diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c4e68e..1396f3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - ✨(frontend) add configurable documentation menu item - ✨(frontend) allow promoting authenticated participants - ✨(frontend) introduce an "unauthenticated" participant badge +- ✨(backend) add roomkit viewset to start a room without WebRTC join ### Changed diff --git a/src/backend/core/analytics/events.py b/src/backend/core/analytics/events.py index d8f3e7ec..49d5a6bc 100644 --- a/src/backend/core/analytics/events.py +++ b/src/backend/core/analytics/events.py @@ -8,3 +8,6 @@ class AnalyticsEvent(StrEnum): # Rooms ROOM_CREATED = "room_created" + + # Roomkit (meeting-room SIP devices) + ROOMKIT_JOINED = "roomkit_joined" diff --git a/src/backend/core/api/feature_flag.py b/src/backend/core/api/feature_flag.py index fe49ff5d..db9251fb 100644 --- a/src/backend/core/api/feature_flag.py +++ b/src/backend/core/api/feature_flag.py @@ -16,6 +16,7 @@ class FeatureFlag: "file_upload": "FILE_UPLOAD_ENABLED", "addons": "ADDONS_ENABLED", "application": "APPLICATION_ENABLED", + "roomkit": "ROOMKIT_ENABLED", } @classmethod diff --git a/src/backend/core/api/throttling.py b/src/backend/core/api/throttling.py index b7b89b43..703e148d 100644 --- a/src/backend/core/api/throttling.py +++ b/src/backend/core/api/throttling.py @@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle): """Throttle Anonymous user requesting room generation callback""" scope = "creation_callback" + + +class RoomKitJoinRateThrottle(MonitoredUserRateThrottle): + """Throttle the LiveKit SIP module requesting roomkit joins. + + The roomkit endpoints are authenticated as a machine user, so all requests + share a single throttle bucket. This is not a security measure against + brute-force attacks but a guard against accidental hammering from a buggy + SIP module. + """ + + scope = "roomkit_join" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index fbfcf6be..f856cdfc 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -429,7 +429,14 @@ class Room(Resource): def save(self, *args, **kwargs): """Generate a unique n-digit pin code for new rooms.""" - if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code: + + # Roomkit devices also join by PIN, so a PIN is needed as soon as + # either integration is enabled. + if ( + (settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED) + and not self.pk + and not self.pin_code + ): self.pin_code = self.generate_unique_pin_code( length=settings.ROOM_TELEPHONY_PIN_LENGTH ) diff --git a/src/backend/core/roomkit/__init__.py b/src/backend/core/roomkit/__init__.py new file mode 100644 index 00000000..09974fd7 --- /dev/null +++ b/src/backend/core/roomkit/__init__.py @@ -0,0 +1 @@ +"""Meet core roomkit API endpoints for meeting-room (SIP) device integration.""" diff --git a/src/backend/core/roomkit/authentication.py b/src/backend/core/roomkit/authentication.py new file mode 100644 index 00000000..9f32128d --- /dev/null +++ b/src/backend/core/roomkit/authentication.py @@ -0,0 +1,65 @@ +"""Authentication for the roomkit API of the Meet core app.""" + +import logging +import secrets + +from django.conf import settings + +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + +from core.recording.event.authentication import MachineUser + +logger = logging.getLogger(__name__) + + +class ServerToServerAuthentication(BaseAuthentication): + """Custom authentication class for roomkit server-to-server requests. + + Validates the Authorization header against the roomkit server-to-server + token. A valid PIN code is intentionally not enough to authenticate: the + endpoints are restricted to the LiveKit SIP module's credentials. + """ + + AUTH_HEADER = "Authorization" + TOKEN_TYPE = "Bearer" # noqa S105 + + def authenticate(self, request): + """Validate the Bearer token from the Authorization header. + + Returns a (MachineUser, token) pair on success, and raises + AuthenticationFailed if the header is missing, malformed, or contains + an invalid token. + """ + required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN + if not required_token: + raise AuthenticationFailed("Server-to-server token is not configured.") + + auth_header = request.headers.get(self.AUTH_HEADER) + if not auth_header: + logger.warning( + "Roomkit authentication failed: missing Authorization header (ip: %s)", + request.META.get("REMOTE_ADDR"), + ) + raise AuthenticationFailed("Authorization header is missing.") + + # Validate token format and existence + auth_parts = auth_header.split(" ") + if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE: + raise AuthenticationFailed("Invalid authorization header.") + + token = auth_parts[1] + + # Use constant-time comparison to prevent timing attacks + if not secrets.compare_digest(token.encode(), required_token.encode()): + logger.warning( + "Roomkit authentication failed: invalid token (ip: %s)", + request.META.get("REMOTE_ADDR"), + ) + raise AuthenticationFailed("Invalid server-to-server token.") + + return MachineUser(username="roomkit"), token + + def authenticate_header(self, request): + """Return the WWW-Authenticate header value.""" + return f"{self.TOKEN_TYPE} realm='Roomkit server to server'" diff --git a/src/backend/core/roomkit/serializers.py b/src/backend/core/roomkit/serializers.py new file mode 100644 index 00000000..b580c746 --- /dev/null +++ b/src/backend/core/roomkit/serializers.py @@ -0,0 +1,21 @@ +"""Serializers for the roomkit API of the Meet core app.""" + +# pylint: disable=abstract-method + +from django.conf import settings + +from rest_framework import serializers + +from core.api.serializers import BaseValidationOnlySerializer + + +class RoomKitJoinSerializer(BaseValidationOnlySerializer): + """Validate roomkit join requests from the LiveKit SIP module.""" + + pin_code = serializers.CharField(required=True) + + def validate_pin_code(self, value): + """Ensure the PIN code matches the configured length.""" + if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH: + raise serializers.ValidationError("PIN code length is invalid.") + return value diff --git a/src/backend/core/roomkit/viewsets.py b/src/backend/core/roomkit/viewsets.py new file mode 100644 index 00000000..7792b5d8 --- /dev/null +++ b/src/backend/core/roomkit/viewsets.py @@ -0,0 +1,89 @@ +"""Roomkit API endpoints for meeting-room (SIP) device integration.""" + +from logging import getLogger + +from rest_framework import decorators, viewsets +from rest_framework import ( + exceptions as drf_exceptions, +) +from rest_framework import ( + response as drf_response, +) +from rest_framework import ( + status as drf_status, +) + +from core import analytics, models +from core.api import permissions, throttling +from core.api.feature_flag import FeatureFlag +from core.services.telephony import TelephonyException, TelephonyService + +from . import authentication, serializers + +logger = getLogger(__name__) + + +class RoomKitViewSet(viewsets.ViewSet): + """Server-to-server API endpoints for the roomkit integration. + + Groups all interactions between roomkit (SIP) devices and the backend, + brokered by the LiveKit SIP module. All endpoints are authenticated + with the roomkit server-to-server tokens. + """ + + authentication_classes = [authentication.ServerToServerAuthentication] + permission_classes = [permissions.IsAuthenticated] + + @decorators.action( + detail=False, + methods=["post"], + url_path="join", + throttle_classes=[throttling.RoomKitJoinRateThrottle], + ) + @FeatureFlag.require("roomkit") + def join(self, request): + """Prepare a room for a meeting-room (SIP) device joining by PIN code. + + Called by the LiveKit SIP module when a meeting-room device dials in + with a PIN code before any WebRTC participant has joined. Resolves the + room by PIN and creates its SIP dispatch rule, so the device can enter + without waiting for a WebRTC user. + + The webhook-based creation path is kept: both converge on the same rule + through the shared TelephonyService. + """ + + serializer = serializers.RoomKitJoinSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + room = models.Room.objects.get( + pin_code=serializer.validated_data["pin_code"] + ) + except models.Room.DoesNotExist as e: + raise drf_exceptions.NotFound("No room found for this PIN code.") from e + + try: + created = TelephonyService().ensure_dispatch_rule(room) + except TelephonyException as e: + raise drf_exceptions.APIException("Could not create dispatch rule.") from e + + analytics.capture( + request.user, + analytics.AnalyticsEvent.ROOMKIT_JOINED, + { + "room_id": str(room.pk), + "dispatch_rule_created": created, + }, + ) + + logger.info( + "Roomkit join requested: room_id=%s, dispatch_rule_created=%s", + room.id, + created, + ) + + return drf_response.Response( + {"status": "success"}, + status=drf_status.HTTP_200_OK, + ) diff --git a/src/backend/core/services/livekit_events.py b/src/backend/core/services/livekit_events.py index 8bdf1fd0..c5f44c17 100644 --- a/src/backend/core/services/livekit_events.py +++ b/src/backend/core/services/livekit_events.py @@ -245,7 +245,7 @@ class LiveKitEventsService: except models.Room.DoesNotExist as err: raise ActionFailedError(f"Room with ID {room_id} does not exist") from err - if settings.ROOM_TELEPHONY_ENABLED: + if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED: try: self.telephony_service.create_dispatch_rule(room) except TelephonyException as e: @@ -265,7 +265,7 @@ class LiveKitEventsService: ) raise ActionFailedError("Failed to process room finished event") from e - if settings.ROOM_TELEPHONY_ENABLED: + if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED: try: self.telephony_service.delete_dispatch_rule(room_id) except TelephonyException as e: diff --git a/src/backend/core/services/telephony.py b/src/backend/core/services/telephony.py index b5d622bc..ef570ff8 100644 --- a/src/backend/core/services/telephony.py +++ b/src/backend/core/services/telephony.py @@ -3,7 +3,7 @@ from logging import getLogger from asgiref.sync import async_to_sync -from livekit.api import TwirpError +from livekit.api import TwirpError, TwirpErrorCode from livekit.protocol.sip import ( CreateSIPDispatchRuleRequest, DeleteSIPDispatchRuleRequest, @@ -21,6 +21,10 @@ class TelephonyException(Exception): """Exception raised when telephony operations fail.""" +class DispatchRuleConflictError(TelephonyException): + """Raised when a dispatch rule already exists for the same routing criteria.""" + + class TelephonyService: """Service for managing participant access through the telephony system (SIP).""" @@ -51,6 +55,8 @@ class TelephonyService: try: await lkapi.sip.create_sip_dispatch_rule(create=request) except TwirpError as e: + if e.code == TwirpErrorCode.ALREADY_EXISTS: + raise DispatchRuleConflictError("Dispatch rule already exists") from e logger.exception( "Unexpected error creating dispatch rule for room %s", room.id ) @@ -94,6 +100,28 @@ class TelephonyService: if existing_rule.name == rule_name ] + @async_to_sync + async def has_dispatch_rule(self, room_id): + """Check whether at least one dispatch rule exists for a specific room.""" + return bool(await self._list_dispatch_rules_ids(room_id)) + + def ensure_dispatch_rule(self, room): + """Create the SIP dispatch rule for a room if it does not already exist. + + Returns: + bool: True if a rule was created, False if it already existed. + """ + + if self.has_dispatch_rule(room.pk): + return False + + try: + self.create_dispatch_rule(room) + except DispatchRuleConflictError: + return False + + return True + @async_to_sync async def delete_dispatch_rule(self, room_id): """Delete all SIP inbound dispatch rules associated with a specific room.""" diff --git a/src/backend/core/tests/roomkit/__init__.py b/src/backend/core/tests/roomkit/__init__.py new file mode 100644 index 00000000..fec01a23 --- /dev/null +++ b/src/backend/core/tests/roomkit/__init__.py @@ -0,0 +1 @@ +"""Tests for the roomkit API of the Meet core app.""" diff --git a/src/backend/core/tests/roomkit/test_api_roomkit_join.py b/src/backend/core/tests/roomkit/test_api_roomkit_join.py new file mode 100644 index 00000000..cce2c8b5 --- /dev/null +++ b/src/backend/core/tests/roomkit/test_api_roomkit_join.py @@ -0,0 +1,266 @@ +""" +Test the roomkit join server-to-server API endpoint. +""" + +# pylint: disable=redefined-outer-name,unused-argument + +from unittest import mock + +import pytest + +from ...factories import RoomFactory +from ...services.telephony import TelephonyException + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def mock_telephony_service(): + """Mock the TelephonyService used by the roomkit viewset.""" + with mock.patch("core.roomkit.viewsets.TelephonyService") as mock_service_class: + yield mock_service_class.return_value + + +def test_join_anonymous(settings, mock_telephony_service, client): + """Requests without an Authorization header should be rejected.""" + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + room = RoomFactory(pin_code="1234567890") + + response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code}) + + assert response.status_code == 401 + assert response.json() == {"detail": "Authorization header is missing."} + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_malformed_authorization_header(settings, mock_telephony_service, client): + """Requests with a malformed Authorization header should be rejected.""" + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + room = RoomFactory(pin_code="1234567890") + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": room.pin_code}, + HTTP_AUTHORIZATION="testAuthToken", + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid authorization header."} + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_wrong_bearer(settings, mock_telephony_service, client): + """Requests with an incorrect bearer token should be rejected.""" + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + room = RoomFactory(pin_code="1234567890") + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": room.pin_code}, + HTTP_AUTHORIZATION="Bearer wrongAuthToken", + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid server-to-server token."} + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_token_not_configured(settings, mock_telephony_service, client): + """Requests should be rejected when no server-to-server token is configured.""" + + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None + + room = RoomFactory(pin_code="1234567890") + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": room.pin_code}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 401 + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_roomkit_disabled(settings, mock_telephony_service, client): + """The endpoint should not be exposed when the roomkit integration is disabled.""" + + settings.ROOMKIT_ENABLED = False + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + room = RoomFactory(pin_code="1234567890") + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": room.pin_code}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 404 + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_missing_pin(settings, mock_telephony_service, client): + """Requests without a PIN code should be rejected.""" + + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + response = client.post( + "/api/v1.0/roomkit/join/", + {}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 400 + assert response.json() == {"pin_code": ["This field is required."]} + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_blank_pin(settings, mock_telephony_service, client): + """Requests with a blank PIN code should be rejected.""" + + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": ""}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 400 + assert response.json() == {"pin_code": ["This field may not be blank."]} + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_wrong_pin_length(settings, mock_telephony_service, client): + """Requests with a PIN code of unexpected length should be rejected.""" + + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + settings.ROOM_TELEPHONY_PIN_LENGTH = 10 + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": "123"}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 400 + assert response.json() == {"pin_code": ["PIN code length is invalid."]} + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_unknown_pin(settings, mock_telephony_service, client): + """Requests with a PIN matching no room should return 404 and create no rule.""" + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + RoomFactory(pin_code="1234567890") + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": "0987654321"}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 404 + assert response.json() == {"detail": "No room found for this PIN code."} + mock_telephony_service.ensure_dispatch_rule.assert_not_called() + + +def test_join_success(settings, mock_telephony_service, client): + """Requests with a valid PIN should create the dispatch rule.""" + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + room = RoomFactory(pin_code="1234567890") + mock_telephony_service.ensure_dispatch_rule.return_value = True + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": room.pin_code}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success"} + mock_telephony_service.ensure_dispatch_rule.assert_called_once_with(room) + + +def test_join_dispatch_rule_already_exists(settings, mock_telephony_service, client): + """Requests should succeed when the dispatch rule already exists (idempotency).""" + + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + room = RoomFactory(pin_code="1234567890") + mock_telephony_service.ensure_dispatch_rule.return_value = False + + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": room.pin_code}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 200 + assert response.json() == {"status": "success"} + mock_telephony_service.ensure_dispatch_rule.assert_called_once_with(room) + + +def test_join_tracks_analytics_event(settings, mock_telephony_service, client): + """Successful joins should be tracked with an analytics event.""" + + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + room = RoomFactory(pin_code="1234567890") + mock_telephony_service.ensure_dispatch_rule.return_value = True + + with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture: + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": room.pin_code}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + ) + + assert response.status_code == 200 + mock_capture.assert_called_once() + _user, event, properties = mock_capture.call_args[0] + assert str(event) == "roomkit_joined" + assert properties == { + "room_id": str(room.pk), + "dispatch_rule_created": True, + } + + +def test_join_telephony_failure(settings, mock_telephony_service, client): + """Requests should fail with a server error when the telephony service fails.""" + + settings.ROOMKIT_ENABLED = True + settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken" + + room = RoomFactory(pin_code="1234567890") + mock_telephony_service.ensure_dispatch_rule.side_effect = TelephonyException( + "Could not create dispatch rule" + ) + + with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture: + response = client.post( + "/api/v1.0/roomkit/join/", + {"pin_code": room.pin_code}, + HTTP_AUTHORIZATION="Bearer testAuthToken", + raise_request_exception=False, + ) + + assert response.status_code == 500 + mock_telephony_service.ensure_dispatch_rule.assert_called_once_with(room) + mock_capture.assert_not_called() diff --git a/src/backend/core/tests/services/test_livekit_events.py b/src/backend/core/tests/services/test_livekit_events.py index 16adb661..127b8880 100644 --- a/src/backend/core/tests/services/test_livekit_events.py +++ b/src/backend/core/tests/services/test_livekit_events.py @@ -485,6 +485,24 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule( mock_clear_cache.assert_called_once_with(mock_room_name) +@mock.patch.object(LobbyService, "clear_room_cache") +@mock.patch.object(TelephonyService, "delete_dispatch_rule") +def test_handle_room_finished_deletes_dispatch_rule_when_only_roomkit_enabled( + mock_delete_dispatch_rule, mock_clear_cache, service, settings +): + """Should delete dispatch rule when only roomkit is enabled when room finishes.""" + settings.ROOM_TELEPHONY_ENABLED = False + settings.ROOMKIT_ENABLED = True + mock_room_name = uuid.uuid4() + mock_data = mock.MagicMock() + mock_data.room.name = str(mock_room_name) + + service._handle_room_finished(mock_data) + + mock_delete_dispatch_rule.assert_called_once_with(mock_room_name) + mock_clear_cache.assert_called_once_with(mock_room_name) + + @mock.patch.object(LobbyService, "clear_room_cache") @mock.patch.object(TelephonyService, "delete_dispatch_rule") def test_handle_room_finished_skips_telephony_when_disabled( @@ -492,6 +510,7 @@ def test_handle_room_finished_skips_telephony_when_disabled( ): """Should clear lobby cache but skip dispatch rule deletion when telephony is disabled.""" settings.ROOM_TELEPHONY_ENABLED = False + settings.ROOMKIT_ENABLED = False mock_room_name = uuid.uuid4() mock_data = mock.MagicMock() mock_data.room.name = str(mock_room_name) @@ -541,7 +560,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails( mock_data.room.name = "00000000-0000-0000-0000-000000000000" expected_error = ( - "Failed to delete telephony dispatch rule for room " + "Failed to delete sip dispatch rule for room " "00000000-0000-0000-0000-000000000000" ) @@ -577,12 +596,29 @@ def test_handle_room_started_creates_dispatch_rule_successfully( mock_create_dispatch_rule.assert_called_once_with(room) +@mock.patch.object(TelephonyService, "create_dispatch_rule") +def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled( + mock_create_dispatch_rule, service, settings +): + """Should create dispatch rule when only roomkit is enabled during room start.""" + settings.ROOM_TELEPHONY_ENABLED = False + settings.ROOMKIT_ENABLED = True + room = RoomFactory() + mock_data = mock.MagicMock() + mock_data.room.name = str(room.id) + + service._handle_room_started(mock_data) + + mock_create_dispatch_rule.assert_called_once_with(room) + + @mock.patch.object(TelephonyService, "create_dispatch_rule") def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled( mock_create_dispatch_rule, service, settings ): """Should skip creating telephony dispatch rule when telephony is disabled during room start.""" settings.ROOM_TELEPHONY_ENABLED = False + settings.ROOMKIT_ENABLED = False room = RoomFactory() mock_data = mock.MagicMock() mock_data.room.name = str(room.id) diff --git a/src/backend/core/tests/services/test_telephony_service.py b/src/backend/core/tests/services/test_telephony_service.py index c1b6a0aa..3cdc8ab6 100644 --- a/src/backend/core/tests/services/test_telephony_service.py +++ b/src/backend/core/tests/services/test_telephony_service.py @@ -20,7 +20,11 @@ from livekit.protocol.sip import ( from core.factories import RoomFactory from core.models import RoomAccessLevel -from core.services.telephony import TelephonyException, TelephonyService +from core.services.telephony import ( + DispatchRuleConflictError, + TelephonyException, + TelephonyService, +) pytestmark = pytest.mark.django_db @@ -303,3 +307,126 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules): mock_api.sip.delete_sip_dispatch_rule.assert_called_once() mock_api.aclose.assert_called_once() + + +@mock.patch("core.utils.create_livekit_client") +def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory): + """Test that a LiveKit conflict error raises DispatchRuleConflictError.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_api = create_mock_livekit_client() + mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock( + side_effect=TwirpError( + msg=( + "Dispatch rule for the same trunk, inbound number, number, and " + "PIN combination already exists in dispatch rule" + ), + code="already_exists", + status=409, + ) + ) + mock_client_factory.return_value = mock_api + + with pytest.raises(DispatchRuleConflictError): + telephony_service.create_dispatch_rule(room) + + mock_api.aclose.assert_called_once() + + +@mock.patch("core.utils.create_livekit_client") +def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory): + """Test that ensure_dispatch_rule creates the rule when none exists.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_api = create_mock_livekit_client() + mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( + return_value=ListSIPDispatchRuleResponse(items=[]) + ) + mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock() + mock_client_factory.return_value = mock_api + + created = telephony_service.ensure_dispatch_rule(room) + + assert created is True + mock_api.sip.create_sip_dispatch_rule.assert_called_once() + create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"] + + assert isinstance(create_request, CreateSIPDispatchRuleRequest) + assert create_request.name == f"SIP_{str(room.id)}" + assert create_request.rule.dispatch_rule_direct.room_name == str(room.id) + assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code) + + +@mock.patch("core.utils.create_livekit_client") +def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory): + """Test that ensure_dispatch_rule is idempotent when the rule already exists.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + existing_rule = SIPDispatchRuleInfo( + sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}" + ) + mock_api = create_mock_livekit_client() + mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( + return_value=ListSIPDispatchRuleResponse(items=[existing_rule]) + ) + mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock() + mock_client_factory.return_value = mock_api + + created = telephony_service.ensure_dispatch_rule(room) + + assert created is False + mock_api.sip.create_sip_dispatch_rule.assert_not_called() + + +@mock.patch("core.utils.create_livekit_client") +def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory): + """Test that ensure_dispatch_rule tolerates a concurrent rule creation. + + If the rule is created by a concurrent caller (e.g. the LiveKit webhook) + between the existence check and the creation, LiveKit rejects the + duplicate and ensure_dispatch_rule reports the rule as already existing. + """ + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_api = create_mock_livekit_client() + mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( + return_value=ListSIPDispatchRuleResponse(items=[]) + ) + mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock( + side_effect=TwirpError( + msg=( + "Dispatch rule for the same trunk, inbound number, number, and " + "PIN combination already exists in dispatch rule" + ), + code="already_exists", + status=409, + ) + ) + mock_client_factory.return_value = mock_api + + created = telephony_service.ensure_dispatch_rule(room) + + assert created is False + + +@mock.patch("core.utils.create_livekit_client") +def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory): + """Test that ensure_dispatch_rule propagates unexpected LiveKit failures.""" + telephony_service = TelephonyService() + room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234") + + mock_api = create_mock_livekit_client() + mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( + return_value=ListSIPDispatchRuleResponse(items=[]) + ) + mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock( + side_effect=TwirpError(msg="Internal server error", code="unknown", status=500) + ) + mock_client_factory.return_value = mock_api + + with pytest.raises(TelephonyException, match="Could not create dispatch rule"): + telephony_service.ensure_dispatch_rule(room) diff --git a/src/backend/core/tests/test_models_rooms.py b/src/backend/core/tests/test_models_rooms.py index 23d1f23f..ba30897d 100644 --- a/src/backend/core/tests/test_models_rooms.py +++ b/src/backend/core/tests/test_models_rooms.py @@ -184,12 +184,13 @@ def test_models_rooms_is_public_property(): @mock.patch.object(Room, "generate_unique_pin_code") -def test_telephony_disabled_skips_pin_generation( +def test_telephony_and_roomkit_disabled_skips_pin_generation( mock_generate_unique_pin_code, settings ): - """Telephony disabled should not generate pin codes.""" + """Telephony and roomkit both disabled should not generate pin codes.""" settings.ROOM_TELEPHONY_ENABLED = False + settings.ROOMKIT_ENABLED = False room = RoomFactory() @@ -197,6 +198,18 @@ def test_telephony_disabled_skips_pin_generation( assert room.pin_code is None +def test_roomkit_enabled_generates_pin_code(settings): + """Roomkit enabled alone should generate pin codes, even without telephony.""" + + settings.ROOM_TELEPHONY_ENABLED = False + settings.ROOMKIT_ENABLED = True + + room = RoomFactory() + + assert room.pin_code is not None + assert len(room.pin_code) == settings.ROOM_TELEPHONY_PIN_LENGTH + + def test_default_and_custom_pin_length(settings): """Pin codes should be created with correct configured length.""" diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 3bc4f5e8..81272a1d 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -9,6 +9,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter from core.addons import viewsets as addons_viewsets from core.api import get_frontend_configuration, viewsets from core.external_api import viewsets as external_viewsets +from core.roomkit import viewsets as roomkit_viewsets # - Main endpoints router = DefaultRouter() @@ -19,6 +20,11 @@ router.register("files", viewsets.FileViewSet, basename="files") router.register( "resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses" ) +router.register( + "roomkit", + roomkit_viewsets.RoomKitViewSet, + basename="roomkit", +) router.register( "addons/sessions", addons_viewsets.SessionViewSet, diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index f392917d..02ff0187 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -349,6 +349,11 @@ class Base(Configuration): environ_name="CREATION_CALLBACK_THROTTLE_RATES", environ_prefix=None, ), + "roomkit_join": values.Value( + default="300/minute", + environ_name="ROOMKIT_JOIN_THROTTLE_RATES", + environ_prefix=None, + ), }, } MONITORED_THROTTLE_FAILURE_CALLBACK = ( @@ -881,6 +886,21 @@ class Base(Configuration): environ_prefix=None, ) + # Roomkit (meeting-room SIP devices) integration + ROOMKIT_ENABLED = values.BooleanValue( + False, + environ_name="ROOMKIT_ENABLED", + environ_prefix=None, + ) + # Server-to-server API token allowing the LiveKit SIP module to call the + # roomkit endpoints (e.g. join a room on behalf of a meeting-room device + # dialing in before any WebRTC participant). + ROOMKIT_SERVER_TO_SERVER_API_TOKEN = SecretFileValue( + None, + environ_name="ROOMKIT_SERVER_TO_SERVER_API_TOKEN", + environ_prefix=None, + ) + # Subtitles settings ROOM_SUBTITLE_ENABLED = values.BooleanValue( False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None