Port CallRoutingControllerV2 to gRPC

This commit is contained in:
Jon Chambers 2026-06-09 17:19:16 -04:00 committed by Jon Chambers
parent ae9f43bb3c
commit 82e3c16fba
4 changed files with 221 additions and 12 deletions

View File

@ -152,6 +152,7 @@ import org.whispersystems.textsecuregcm.grpc.AttachmentsGrpcService;
import org.whispersystems.textsecuregcm.grpc.BackupsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.BackupsGrpcService;
import org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService;
import org.whispersystems.textsecuregcm.grpc.CallingGrpcService;
import org.whispersystems.textsecuregcm.grpc.ChallengeGrpcService;
import org.whispersystems.textsecuregcm.grpc.CredentialsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.CredentialsGrpcService;
@ -996,6 +997,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final List<ServerServiceDefinition> authenticatedServices = Stream.of(
new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager),
new CallingGrpcService(cloudflareTurnCredentialsManager, rateLimiters),
new CredentialsGrpcService(accountsManager, certificateGenerator, zkAuthOperations, callingGenericZkSecretParams, rateLimiters, Clock.systemUTC(), ExternalServiceDefinitions.createExternalServiceList(config, Clock.systemUTC())),
new KeysGrpcService(accountsManager, keysManager, rateLimiters),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters),

View File

@ -0,0 +1,74 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import java.io.IOException;
import org.apache.commons.lang3.StringUtils;
import org.signal.chat.calling.GetCallingRelaysRequest;
import org.signal.chat.calling.GetCallingRelaysResponse;
import org.signal.chat.calling.SimpleCallingGrpc;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;
import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
public class CallingGrpcService extends SimpleCallingGrpc.CallingImplBase {
private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
private final RateLimiters rateLimiters;
public CallingGrpcService(final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager,
final RateLimiters rateLimiters) {
this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;
this.rateLimiters = rateLimiters;
}
@Override
public GetCallingRelaysResponse getCallingRelays(final GetCallingRelaysRequest request)
throws RateLimitExceededException, IOException {
final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();
rateLimiters.getCallEndpointLimiter().validate(authenticatedDevice.accountIdentifier());
final TurnToken turnToken =
cloudflareTurnCredentialsManager.retrieveFromCloudflare(authenticatedDevice.accountIdentifier());
final GetCallingRelaysResponse.Relay.Builder relayBuilder = GetCallingRelaysResponse.Relay.newBuilder()
.setUsername(turnToken.username())
.setPassword(turnToken.password())
.setCredentialTtlSeconds(turnToken.ttlSeconds());
if (!turnToken.urls().isEmpty()) {
relayBuilder.setHostnameUrls(turnToken.urls().stream()
.collect(GetCallingRelaysResponse.HostnameUrlList::newBuilder,
GetCallingRelaysResponse.HostnameUrlList.Builder::addUrls,
(a, b) -> a.mergeFrom(b.build())));
}
if (!turnToken.urlsWithIps().isEmpty()) {
relayBuilder.setIpUrls(turnToken.urlsWithIps().stream()
.collect(() -> {
final GetCallingRelaysResponse.IpUrlList.Builder builder =
GetCallingRelaysResponse.IpUrlList.newBuilder();
if (StringUtils.isNotBlank(turnToken.hostname())) {
builder.setHostname(turnToken.hostname());
}
return builder;
},
GetCallingRelaysResponse.IpUrlList.Builder::addUrls,
(a, b) -> a.mergeFrom(b.build())));
}
return GetCallingRelaysResponse.newBuilder()
.addRelays(relayBuilder.build())
.build();
}
}

View File

@ -9,23 +9,53 @@ option java_multiple_files = true;
package org.signal.chat.calling;
// Provides methods for getting credentials for one-on-one and group calls.
// Provides methods for getting credentials and relay options for one-on-one
// calls.
service Calling {
// Generates and returns TURN credentials for the caller.
rpc GetTurnCredentials(GetTurnCredentialsRequest) returns (GetTurnCredentialsResponse) {}
// Retrieves TURN credentials and relay options for one-on-one calls.
rpc GetCallingRelays(GetCallingRelaysRequest) returns (GetCallingRelaysResponse) {}
}
message GetTurnCredentialsRequest {}
message GetCallingRelaysRequest {}
message GetTurnCredentialsResponse {
// A username that can be presented to authenticate with a TURN server.
string username = 1;
message GetCallingRelaysResponse {
message HostnameUrlList {
// A collection of hostname-based TURN, TURNS, or STUN URLs a client can use
// to connect to a relay.
repeated string urls = 1;
}
// A password that can be presented to authenticate with a TURN server.
string password = 2;
message IpUrlList {
// A collection of IP-based TURN, TURNS, or STUN URLs a client can use to
// connect to a relay.
repeated string urls = 1;
// A list of TURN (or TURNS or STUN) servers where the provided credentials
// may be used.
repeated string urls = 3;
// A hostname clients must use to validate the relay's TLS certificate when
// connecting via an IP-based TURNS URL. May not be specified if `urls`
// contains no TURNS URLs.
optional string hostname = 2;
}
message Relay {
// A username that can be presented to authenticate with a TURN server.
string username = 1;
// A password that can be presented to authenticate with a TURN server.
string password = 2;
// The duration, in seconds, after which the included username and password
// will no longer be valid.
uint64 credential_ttl_seconds = 3;
// A collection of hostname-based URLs clients may use to connect to this
// relay.
optional HostnameUrlList hostname_urls = 4;
// A collection of IP-based URLs clients may use to connect to this relay.
optional IpUrlList ip_urls = 5;
}
// A collection of calling relays a client may use for one-on-one calls.
repeated Relay relays = 1;
}

View File

@ -0,0 +1,103 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.grpc.Status;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.signal.chat.calling.CallingGrpc;
import org.signal.chat.calling.GetCallingRelaysRequest;
import org.signal.chat.calling.GetCallingRelaysResponse;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
class CallingGrpcServiceTest extends SimpleBaseGrpcTest<CallingGrpcService, CallingGrpc.CallingBlockingStub> {
@Mock
private CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
@Mock
private RateLimiter rateLimiter;
@Override
protected CallingGrpcService createServiceBeforeEachTest() {
final RateLimiters rateLimiters = mock(RateLimiters.class);
when(rateLimiters.getCallEndpointLimiter()).thenReturn(rateLimiter);
return new CallingGrpcService(cloudflareTurnCredentialsManager, rateLimiters);
}
@Test
void getCallingRelays() throws IOException {
final String username = "username";
final String password = "password";
final long credentialTtlSeconds = 73;
final String hostnameTurnUrl = "turn:example.com:443";
final String ipTurnUrl = "turn:127.0.0.1:80";
final String ipTurnsUrl = "turns:127.0.0.1:443";
final String hostname = "example.com";
when(cloudflareTurnCredentialsManager.retrieveFromCloudflare(any()))
.thenReturn(new TurnToken(username,
password,
credentialTtlSeconds,
List.of(hostnameTurnUrl),
List.of(ipTurnUrl, ipTurnsUrl),
hostname));
assertEquals(GetCallingRelaysResponse.newBuilder()
.addRelays(GetCallingRelaysResponse.Relay.newBuilder()
.setUsername(username)
.setPassword(password)
.setCredentialTtlSeconds(credentialTtlSeconds)
.setHostnameUrls(GetCallingRelaysResponse.HostnameUrlList.newBuilder()
.addUrls(hostnameTurnUrl)
.build())
.setIpUrls(GetCallingRelaysResponse.IpUrlList.newBuilder()
.addUrls(ipTurnUrl)
.addUrls(ipTurnsUrl)
.setHostname(hostname)
.build())
.build())
.build(),
authenticatedServiceStub().getCallingRelays(GetCallingRelaysRequest.getDefaultInstance()));
}
@Test
void getCallingRelaysIoException() throws IOException {
when(cloudflareTurnCredentialsManager.retrieveFromCloudflare(any()))
.thenThrow(IOException.class);
//noinspection ResultOfMethodCallIgnored,ThrowableNotThrown
GrpcTestUtils.assertStatusException(Status.UNAVAILABLE,
() -> authenticatedServiceStub().getCallingRelays(GetCallingRelaysRequest.getDefaultInstance()));
}
@Test
void getCallingRelaysRateLimited() throws RateLimitExceededException {
final Duration retryAfter = Duration.ofSeconds(19);
doThrow(new RateLimitExceededException(retryAfter))
.when(rateLimiter).validate(any(UUID.class));
//noinspection ResultOfMethodCallIgnored
GrpcTestUtils.assertRateLimitExceeded(retryAfter,
() -> authenticatedServiceStub().getCallingRelays(GetCallingRelaysRequest.getDefaultInstance()));
}
}