diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index e065ec000..7a5dfa712 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -107,6 +107,7 @@ import org.whispersystems.textsecuregcm.controllers.AccountControllerV2; import org.whispersystems.textsecuregcm.controllers.ArchiveController; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4; import org.whispersystems.textsecuregcm.controllers.CallLinkController; +import org.whispersystems.textsecuregcm.controllers.CallQualitySurveyController; import org.whispersystems.textsecuregcm.controllers.CallRoutingControllerV2; import org.whispersystems.textsecuregcm.controllers.CertificateController; import org.whispersystems.textsecuregcm.controllers.ChallengeController; @@ -142,6 +143,7 @@ import org.whispersystems.textsecuregcm.filters.RestDeprecationFilter; import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter; import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService; +import org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService; import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor; import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService; @@ -875,6 +877,7 @@ public class WhisperServerService extends Application unauthenticatedServices = Stream.of( new AccountsAnonymousGrpcService(accountsManager, rateLimiters), + new CallQualitySurveyGrpcService(callQualitySurveyManager, rateLimiters), new KeysAnonymousGrpcService(accountsManager, keysManager, zkSecretParams, Clock.systemUTC()), new PaymentsGrpcService(currencyManager), ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config), @@ -1052,6 +1055,7 @@ public class WhisperServerService extends Application authenticatedDevice, + @RequestBody(description = "A serialized survey response protobuf entity") + @NotNull final byte[] surveyResponse, + @HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString, + @Context final ContainerRequestContext requestContext) { + + if (authenticatedDevice.isPresent()) { + throw new ForbiddenException("must not use authenticated connection for call quality survey submissions"); + } + + final SubmitCallQualitySurveyRequest submitCallQualitySurveyRequest; + + try { + submitCallQualitySurveyRequest = SubmitCallQualitySurveyRequest.parseFrom(surveyResponse); + } catch (final InvalidProtocolBufferException e) { + throw new WebApplicationException(422); + } + + final String remoteAddress = (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME); + + callQualitySurveyManager.submitCallQualitySurvey(submitCallQualitySurveyRequest, remoteAddress, userAgentString); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CallQualitySurveyGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CallQualitySurveyGrpcService.java new file mode 100644 index 000000000..cb6ecfc4f --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/CallQualitySurveyGrpcService.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import org.signal.chat.calling.quality.SimpleCallQualityGrpc; +import org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest; +import org.signal.chat.calling.quality.SubmitCallQualitySurveyResponse; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager; + +public class CallQualitySurveyGrpcService extends SimpleCallQualityGrpc.CallQualityImplBase { + + private final CallQualitySurveyManager callQualitySurveyManager; + private final RateLimiters rateLimiters; + + public CallQualitySurveyGrpcService(final CallQualitySurveyManager callQualitySurveyManager, + final RateLimiters rateLimiters) { + + this.callQualitySurveyManager = callQualitySurveyManager; + this.rateLimiters = rateLimiters; + } + + @Override + public SubmitCallQualitySurveyResponse submitCallQualitySurvey(final SubmitCallQualitySurveyRequest request) + throws RateLimitExceededException { + + final String remoteAddress = RequestAttributesUtil.getRemoteAddress().getHostAddress(); + + rateLimiters.getSubmitCallQualitySurveyLimiter().validate(remoteAddress); + + callQualitySurveyManager.submitCallQualitySurvey(request, + remoteAddress, + RequestAttributesUtil.getUserAgent().orElse(null)); + + return SubmitCallQualitySurveyResponse.getDefaultInstance(); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java index 138bcf10a..de9359906 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java @@ -56,6 +56,7 @@ public class RateLimiters extends BaseRateLimiters { RECORD_DEVICE_TRANSFER_REQUEST("recordDeviceTransferRequest", new RateLimiterConfig(10, Duration.ofMillis(100), true)), WAIT_FOR_DEVICE_TRANSFER_REQUEST("waitForDeviceTransferRequest", new RateLimiterConfig(10, Duration.ofMillis(100), true)), DEVICE_CHECK_CHALLENGE("deviceCheckChallenge", new RateLimiterConfig(10, Duration.ofMinutes(1), false)), + SUBMIT_CALL_QUALITY_SURVERY("submitCallQualitySurvey", new RateLimiterConfig(100, Duration.ofMinutes(1), true)) ; private final String id; @@ -221,4 +222,8 @@ public class RateLimiters extends BaseRateLimiters { public RateLimiter getKeyTransparencyMonitorLimiter() { return forDescriptor(For.KEY_TRANSPARENCY_MONITOR_PER_IP); } + + public RateLimiter getSubmitCallQualitySurveyLimiter() { + return forDescriptor(For.SUBMIT_CALL_QUALITY_SURVERY); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallQualitySurveyControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallQualitySurveyControllerTest.java new file mode 100644 index 000000000..5980c3526 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallQualitySurveyControllerTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.controllers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; + +import io.dropwizard.auth.AuthValueFactoryProvider; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.dropwizard.testing.junit5.ResourceExtension; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; +import org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider; + +@ExtendWith(DropwizardExtensionsSupport.class) +class CallQualitySurveyControllerTest { + + private static final CallQualitySurveyManager CALL_QUALITY_SURVEY_MANAGER = mock(CallQualitySurveyManager.class); + + private static final String USER_AGENT = "Signal-iOS/7.78.0.1041 iOS/18.3.2 libsignal/0.80.3"; + private static final String REMOTE_ADDRESS = "127.0.0.1"; + + private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class)) + .addProvider(new RateLimitExceededExceptionMapper()) + .addProvider(new TestRemoteAddressFilterProvider(REMOTE_ADDRESS)) + .setMapper(SystemMapper.jsonMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new CallQualitySurveyController(CALL_QUALITY_SURVEY_MANAGER)) + .build(); + + @BeforeEach + void setUp() { + reset(CALL_QUALITY_SURVEY_MANAGER); + } + + @Test + void submitCallQualitySurvey() { + final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.getDefaultInstance(); + + try (final Response response = RESOURCE_EXTENSION.getJerseyTest() + .target("/v1/call_quality_survey") + .request() + .header("User-Agent", USER_AGENT) + .put(Entity.entity(request.toByteArray(), MediaType.APPLICATION_OCTET_STREAM_TYPE))) { + + assertEquals(204, response.getStatus()); + verify(CALL_QUALITY_SURVEY_MANAGER).submitCallQualitySurvey(request, REMOTE_ADDRESS, USER_AGENT); + } + } + + @Test + void submitCallQualitySurveyAuthenticated() { + final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.getDefaultInstance(); + + try (final Response response = RESOURCE_EXTENSION.getJerseyTest() + .target("/v1/call_quality_survey") + .request() + .header("User-Agent", USER_AGENT) + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .put(Entity.entity(request.toByteArray(), MediaType.APPLICATION_OCTET_STREAM_TYPE))) { + + assertEquals(403, response.getStatus()); + verify(CALL_QUALITY_SURVEY_MANAGER, never()).submitCallQualitySurvey(any(), any(), any()); + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CallQualitySurveyGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CallQualitySurveyGrpcServiceTest.java new file mode 100644 index 000000000..4af14d07c --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/CallQualitySurveyGrpcServiceTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.net.InetAddresses; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.signal.chat.calling.quality.CallQualityGrpc; +import org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.limits.RateLimiter; +import org.whispersystems.textsecuregcm.limits.RateLimiters; +import org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager; + +class CallQualitySurveyGrpcServiceTest extends SimpleBaseGrpcTest { + + @Mock + private CallQualitySurveyManager callQualitySurveyManager; + + @Mock + private RateLimiter rateLimiter; + + private static final String USER_AGENT = "Signal-iOS/7.78.0.1041 iOS/18.3.2 libsignal/0.80.3"; + private static final String REMOTE_ADDRESS = "127.0.0.1"; + + @BeforeEach + void setUp() { + getMockRequestAttributesInterceptor() + .setRequestAttributes(new RequestAttributes(InetAddresses.forString(REMOTE_ADDRESS), USER_AGENT, null)); + } + + @Override + protected CallQualitySurveyGrpcService createServiceBeforeEachTest() { + final RateLimiters rateLimiters = mock(RateLimiters.class); + when(rateLimiters.getSubmitCallQualitySurveyLimiter()).thenReturn(rateLimiter); + + return new CallQualitySurveyGrpcService(callQualitySurveyManager, rateLimiters); + } + + @Test + void submitCallQualitySurvey() { + final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.getDefaultInstance(); + assertDoesNotThrow(() -> unauthenticatedServiceStub().submitCallQualitySurvey(request)); + + verify(callQualitySurveyManager).submitCallQualitySurvey(request, REMOTE_ADDRESS, USER_AGENT); + } + + @Test + void submitCallQualitySurveyRateLimited() throws RateLimitExceededException { + final Duration retryAfter = Duration.ofMinutes(17); + + doThrow(new RateLimitExceededException(retryAfter)) + .when(rateLimiter).validate(REMOTE_ADDRESS); + + //noinspection ResultOfMethodCallIgnored + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> unauthenticatedServiceStub().submitCallQualitySurvey(SubmitCallQualitySurveyRequest.getDefaultInstance())); + } +}