Retrieve Cloudflare Turn Credentials from Cloudflare

This commit is contained in:
Alan Liu 2024-06-05 09:03:40 -07:00 committed by GitHub
parent 01743e5c88
commit ffb81e4ff7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 320 additions and 61 deletions

View File

@ -91,8 +91,7 @@ currentReportingKey.secret: AAAAAAAAAAA=
currentReportingKey.salt: AAAAAAAAAAA=
turn.secret: AAAAAAAAAAA=
turn.cloudflare.username: ABCDEFGHIJKLM
turn.cloudflare.password: NOPQRSTUVWXYZ
turn.cloudflare.apiToken: ABCDEFGHIJKLM
linkDevice.secret: AAAAAAAAAAA=

View File

@ -429,10 +429,15 @@ registrationService:
turn:
secret: secret://turn.secret
cloudflare:
username: secret://turn.cloudflare.username
password: secret://turn.cloudflare.password
apiToken: secret://turn.cloudflare.apiToken
endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate
urls:
- turns:turn.cloudflare.example.com:443?transport=tcp
- turn:turn.example.com:80
urlsWithIps:
- turn:%s
- turn:%s:80?transport=tcp
- turns:%s:443?transport=tcp
ttl: 86400
hostname: turn.cloudflare.example.com
linkDevice:

View File

@ -28,6 +28,11 @@ import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.binder.grpc.MetricCollectingServerInterceptor;
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
import io.netty.channel.local.LocalAddress;
import io.netty.channel.socket.nio.NioDatagramChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.resolver.ResolvedAddressTypes;
import io.netty.resolver.dns.DnsNameResolver;
import io.netty.resolver.dns.DnsNameResolverBuilder;
import java.io.FileInputStream;
import java.net.http.HttpClient;
import java.security.KeyStore;
@ -73,6 +78,7 @@ import org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;
import org.whispersystems.textsecuregcm.auth.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
@ -539,9 +545,24 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.workQueue(new SynchronousQueue<>())
.keepAliveTime(io.dropwizard.util.Duration.seconds(60L))
.build();
ExecutorService cloudflareTurnHttpExecutor = environment.lifecycle()
.executorService(name(getClass(), "cloudflareTurn-%d"))
.maxThreads(2)
.minThreads(2)
.build();
ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "subscriptionProcessorRetry-%d")).threads(1).build();
ScheduledExecutorService cloudflareTurnRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "cloudflareTurnRetry-%d")).threads(1).build();
final ManagedNioEventLoopGroup dnsResolutionEventLoopGroup = new ManagedNioEventLoopGroup();
final DnsNameResolver cloudflareDnsResolver = new DnsNameResolverBuilder(dnsResolutionEventLoopGroup.next())
.resolvedAddressTypes(ResolvedAddressTypes.IPV6_PREFERRED)
.completeOncePreferredResolved(false)
.channelType(NioDatagramChannel.class)
.socketChannelType(NioSocketChannel.class)
.build();
ExternalServiceCredentialsGenerator directoryV2CredentialsGenerator = DirectoryV2Controller.credentialsGenerator(
config.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration());
@ -635,7 +656,20 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
pushLatencyManager);
final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager,
config.getTurnConfiguration().secret().value(), config.getTurnConfiguration().cloudflare());
config.getTurnConfiguration().secret().value());
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
config.getTurnConfiguration().cloudflare().apiToken().value(),
config.getTurnConfiguration().cloudflare().endpoint(),
config.getTurnConfiguration().cloudflare().ttl(),
config.getTurnConfiguration().cloudflare().urls(),
config.getTurnConfiguration().cloudflare().urlsWithIps(),
config.getTurnConfiguration().cloudflare().hostname(),
config.getTurnConfiguration().cloudflare().circuitBreaker(),
cloudflareTurnHttpExecutor,
config.getTurnConfiguration().cloudflare().retry(),
cloudflareTurnRetryExecutor,
cloudflareDnsResolver
);
final CardinalityEstimator messageByteLimitCardinalityEstimator = new CardinalityEstimator(
rateLimitersCluster,
@ -887,6 +921,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getNoiseWebSocketTunnelConfiguration().recognizedProxySecret().value());
environment.lifecycle().manage(localEventLoopGroup);
environment.lifecycle().manage(dnsResolutionEventLoopGroup);
environment.lifecycle().manage(anonymousGrpcServer);
environment.lifecycle().manage(authenticatedGrpcServer);
environment.lifecycle().manage(noiseWebSocketEventLoopGroup);
@ -1018,7 +1053,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator,
experimentEnrollmentManager),
new ArchiveController(backupAuthManager, backupManager),
new CallRoutingController(rateLimiters, callRouter, turnTokenGenerator, experimentEnrollmentManager),
new CallRoutingController(rateLimiters, callRouter, turnTokenGenerator, experimentEnrollmentManager, cloudflareTurnCredentialsManager),
new CallLinkController(rateLimiters, callingGenericZkSecretParams),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(),
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()),

View File

@ -0,0 +1,116 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.netty.resolver.dns.DnsNameResolver;
import java.io.IOException;
import java.net.Inet6Address;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import javax.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
import org.whispersystems.textsecuregcm.util.ExceptionUtils;
import org.whispersystems.textsecuregcm.util.SystemMapper;
public class CloudflareTurnCredentialsManager {
private static final Logger logger = LoggerFactory.getLogger(CloudflareTurnCredentialsManager.class);
private final List<String> cloudflareTurnUrls;
private final List<String> cloudflareTurnUrlsWithIps;
private final String cloudflareTurnHostname;
private final HttpRequest request;
private final FaultTolerantHttpClient cloudflareTurnClient;
private final DnsNameResolver dnsNameResolver;
record CredentialRequest(long ttl) {}
record CloudflareTurnResponse(IceServer iceServers) {
record IceServer(
String username,
String credential,
List<String> urls) {
}
}
public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken,
final String cloudflareTurnEndpoint, final long cloudflareTurnTtl, final List<String> cloudflareTurnUrls,
final List<String> cloudflareTurnUrlsWithIps, final String cloudflareTurnHostname,
final CircuitBreakerConfiguration circuitBreaker, final ExecutorService executor, final RetryConfiguration retry,
final ScheduledExecutorService retryExecutor, final DnsNameResolver dnsNameResolver) {
this.cloudflareTurnClient = FaultTolerantHttpClient.newBuilder()
.withName("cloudflare-turn")
.withCircuitBreaker(circuitBreaker)
.withExecutor(executor)
.withRetry(retry)
.withRetryExecutor(retryExecutor)
.build();
this.cloudflareTurnUrls = cloudflareTurnUrls;
this.cloudflareTurnUrlsWithIps = cloudflareTurnUrlsWithIps;
this.cloudflareTurnHostname = cloudflareTurnHostname;
this.dnsNameResolver = dnsNameResolver;
try {
final String body = SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(cloudflareTurnTtl));
this.request = HttpRequest.newBuilder()
.uri(URI.create(cloudflareTurnEndpoint))
.header("Content-Type", "application/json")
.header("Authorization", String.format("Bearer %s", cloudflareTurnApiToken))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
} catch (JsonProcessingException e) {
throw new IllegalArgumentException(e);
}
}
public TurnToken retrieveFromCloudflare() throws IOException {
final List<String> cloudflareTurnComposedUrls;
try {
cloudflareTurnComposedUrls = dnsNameResolver.resolveAll(cloudflareTurnHostname).get().stream()
.map(i -> switch (i) {
case Inet6Address i6 -> "[" + i6.getHostAddress() + "]";
default -> i.getHostAddress();
})
.flatMap(i -> cloudflareTurnUrlsWithIps.stream().map(u -> u.formatted(i)))
.toList();
} catch (Exception e) {
throw new IOException(e);
}
final HttpResponse<String> response;
try {
response = cloudflareTurnClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
} catch (CompletionException e) {
logger.warn("failed to make http request to Cloudflare Turn: {}", e.getMessage());
throw new IOException(ExceptionUtils.unwrap(e));
}
if (response.statusCode() != Response.Status.CREATED.getStatusCode()) {
logger.warn("failure request credentials from Cloudflare Turn (code={}): {}", response.statusCode(), response);
throw new IOException("Cloudflare Turn http failure : " + response.statusCode());
}
final CloudflareTurnResponse cloudflareTurnResponse = SystemMapper.jsonMapper()
.readValue(response.body(), CloudflareTurnResponse.class);
return new TurnToken(cloudflareTurnResponse.iceServers().username(),
cloudflareTurnResponse.iceServers().credential(),
cloudflareTurnUrls, cloudflareTurnComposedUrls, cloudflareTurnHostname);
}
}

View File

@ -17,7 +17,6 @@ import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
import org.whispersystems.textsecuregcm.configuration.CloudflareTurnConfiguration;
import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicTurnConfiguration;
@ -38,21 +37,10 @@ public class TurnTokenGenerator {
private static final String WithIpsProtocol = "01";
private final String cloudflareTurnUsername;
private final String cloudflareTurnPassword;
private final List<String> cloudflareTurnUrls;
private final String cloudflareTurnHostname;
public TurnTokenGenerator(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final byte[] turnSecret, final CloudflareTurnConfiguration cloudflareTurnConfiguration) {
final byte[] turnSecret) {
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.turnSecret = turnSecret;
this.cloudflareTurnUsername = cloudflareTurnConfiguration.username().value();
this.cloudflareTurnPassword = cloudflareTurnConfiguration.password().value();
this.cloudflareTurnUrls = cloudflareTurnConfiguration.urls();
this.cloudflareTurnHostname = cloudflareTurnConfiguration.hostname();
}
@Deprecated
@ -64,10 +52,6 @@ public class TurnTokenGenerator {
return generateToken(options.hostname(), options.urlsWithIps(), options.urlsWithHostname());
}
public TurnToken generateForCloudflareBeta() {
return new TurnToken(cloudflareTurnUsername, cloudflareTurnPassword, cloudflareTurnUrls, null, cloudflareTurnHostname);
}
private TurnToken generateToken(String hostname, List<String> urlsWithIps, List<String> urlsWithHostname) {
try {
final Mac mac = Mac.getInstance(ALGORITHM);

View File

@ -11,7 +11,25 @@ import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public record CloudflareTurnConfiguration(@NotNull SecretString username, @NotNull SecretString password,
@Valid @NotNull List<@NotBlank String> urls, @NotBlank String hostname) {
public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
@NotBlank String endpoint,
@NotBlank long ttl,
@NotBlank List<String> urls,
@NotBlank List<String> urlsWithIps,
@NotNull @Valid CircuitBreakerConfiguration circuitBreaker,
@NotNull @Valid RetryConfiguration retry,
@NotBlank String hostname) {
public CloudflareTurnConfiguration {
if (circuitBreaker == null) {
// Its a little counter-intuitive, but this compact constructor allows a default value
// to be used when one isnt specified (e.g. in YAML), allowing the field to still be
// validated as @NotNull
circuitBreaker = new CircuitBreakerConfiguration();
}
if (retry == null) {
retry = new RetryConfiguration();
}
}
}

View File

@ -7,6 +7,7 @@ import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Optional;
@ -20,6 +21,7 @@ import javax.ws.rs.core.MediaType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter;
@ -40,17 +42,20 @@ public class CallRoutingController {
private final TurnCallRouter turnCallRouter;
private final TurnTokenGenerator tokenGenerator;
private final ExperimentEnrollmentManager experimentEnrollmentManager;
private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
public CallRoutingController(
final RateLimiters rateLimiters,
final TurnCallRouter turnCallRouter,
final TurnTokenGenerator tokenGenerator,
final ExperimentEnrollmentManager experimentEnrollmentManager
final ExperimentEnrollmentManager experimentEnrollmentManager,
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager
) {
this.rateLimiters = rateLimiters;
this.turnCallRouter = turnCallRouter;
this.tokenGenerator = tokenGenerator;
this.experimentEnrollmentManager = experimentEnrollmentManager;
this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;
}
@GET
@ -70,12 +75,12 @@ public class CallRoutingController {
public TurnToken getCallingRelays(
final @ReadOnly @Auth AuthenticatedAccount auth,
@Context ContainerRequestContext requestContext
) throws RateLimitExceededException {
) throws RateLimitExceededException, IOException {
UUID aci = auth.getAccount().getUuid();
rateLimiters.getCallEndpointLimiter().validate(aci);
if (experimentEnrollmentManager.isEnrolled(aci, "cloudflareTurn")) {
return tokenGenerator.generateForCloudflareBeta();
return cloudflareTurnCredentialsManager.retrieveFromCloudflare();
}
Optional<InetAddress> address = Optional.empty();

View File

@ -0,0 +1,97 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.auth;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import io.netty.resolver.dns.DnsNameResolver;
import io.netty.util.concurrent.Future;
import java.io.IOException;
import java.net.InetAddress;
import java.security.cert.CertificateException;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
public class CloudflareTurnCredentialsManagerTest {
@RegisterExtension
private final WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort().dynamicHttpsPort())
.build();
private static final String GET_CREDENTIALS_PATH = "/v1/turn/keys/LMNOP/credentials/generate";
private static final String TURN_HOSTNAME = "localhost";
private ExecutorService httpExecutor;
private ScheduledExecutorService retryExecutor;
private DnsNameResolver dnsResolver;
private Future<List<InetAddress>> dnsResult;
private CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = null;
@BeforeEach
void setUp() throws CertificateException {
httpExecutor = Executors.newSingleThreadExecutor();
retryExecutor = Executors.newSingleThreadScheduledExecutor();
dnsResolver = mock(DnsNameResolver.class);
dnsResult = mock(Future.class);
cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
"API_TOKEN",
"http://localhost:" + wireMock.getPort() + GET_CREDENTIALS_PATH,
100,
List.of("turn:cf.example.com"),
List.of("turn:%s", "turn:%s:80?transport=tcp", "turns:%s:443?transport=tcp"),
TURN_HOSTNAME,
new CircuitBreakerConfiguration(),
httpExecutor,
new RetryConfiguration(),
retryExecutor,
dnsResolver
);
}
@AfterEach
void tearDown() throws InterruptedException {
httpExecutor.shutdown();
httpExecutor.awaitTermination(1, TimeUnit.SECONDS);
retryExecutor.shutdown();
retryExecutor.awaitTermination(1, TimeUnit.SECONDS);
}
@Test
public void testSuccess() throws IOException, CancellationException, ExecutionException, InterruptedException {
wireMock.stubFor(post(urlEqualTo(GET_CREDENTIALS_PATH))
.willReturn(aResponse().withStatus(201).withHeader("Content-Type", new String[]{"application/json"}).withBody("{\"iceServers\":{\"urls\":[\"turn:cloudflare.example.com:3478?transport=udp\"],\"username\":\"ABC\",\"credential\":\"XYZ\"}}")));
when(dnsResult.get())
.thenReturn(List.of(InetAddress.getByName("127.0.0.1"), InetAddress.getByName("::1")));
when(dnsResolver.resolveAll(TURN_HOSTNAME))
.thenReturn(dnsResult);
TurnToken token = cloudflareTurnCredentialsManager.retrieveFromCloudflare();
assertThat(token.username()).isEqualTo("ABC");
assertThat(token.password()).isEqualTo("XYZ");
assertThat(token.hostname()).isEqualTo("localhost");
assertThat(token.urlsWithIps()).containsAll(List.of("turn:127.0.0.1", "turn:127.0.0.1:80?transport=tcp", "turns:127.0.0.1:443?transport=tcp", "turn:[0:0:0:0:0:0:0:1]", "turn:[0:0:0:0:0:0:0:1]:80?transport=tcp", "turns:[0:0:0:0:0:0:0:1]:443?transport=tcp"));;
assertThat(token.urls()).isEqualTo(List.of("turn:cf.example.com"));
}
}

View File

@ -6,22 +6,15 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.CloudflareTurnConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
public class TurnTokenGeneratorTest {
private static final CloudflareTurnConfiguration CLOUDFLARE_TURN_CONFIGURATION = new CloudflareTurnConfiguration(
new SecretString("cf_username"), new SecretString("cf_password"), List.of("turn:cloudflare.example.com"), "cloudflare.example.com");
@Test
public void testAlwaysSelectFirst() throws JsonProcessingException {
final String configString = """
@ -47,8 +40,7 @@ public class TurnTokenGeneratorTest {
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final TurnTokenGenerator turnTokenGenerator =
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8),
CLOUDFLARE_TURN_CONFIGURATION);
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8));
final long COUNT = 1000;
@ -88,9 +80,9 @@ public class TurnTokenGeneratorTest {
DynamicConfigurationManager.class);
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final TurnTokenGenerator turnTokenGenerator =
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8),
CLOUDFLARE_TURN_CONFIGURATION);
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8));
final long COUNT = 1000;
@ -133,8 +125,7 @@ public class TurnTokenGeneratorTest {
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final TurnTokenGenerator turnTokenGenerator =
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8),
CLOUDFLARE_TURN_CONFIGURATION);
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8));
TurnToken token = turnTokenGenerator.generate(UUID.fromString("732506d7-d04f-43a4-b1d7-8a3a91ebe8a6"));
assertThat(token.urls().get(0)).isEqualTo("enrolled.org");

View File

@ -16,6 +16,7 @@ import static org.mockito.Mockito.when;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
@ -28,13 +29,12 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
import org.whispersystems.textsecuregcm.configuration.CloudflareTurnConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
@ -57,9 +57,10 @@ class CallRoutingControllerTest {
private static final ExperimentEnrollmentManager experimentEnrollmentManager = mock(
ExperimentEnrollmentManager.class);
private static final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager,
"bloop".getBytes(StandardCharsets.UTF_8),
new CloudflareTurnConfiguration(new SecretString("cf_username"), new SecretString("cf_password"),
List.of("turn:cf.example.com"), "cf.example.com"));
"bloop".getBytes(StandardCharsets.UTF_8));
private static final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = mock(
CloudflareTurnCredentialsManager.class);
private static final TurnCallRouter turnCallRouter = mock(TurnCallRouter.class);
private static final ResourceExtension resources = ResourceExtension.builder()
@ -70,7 +71,7 @@ class CallRoutingControllerTest {
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new CallRoutingController(rateLimiters, turnCallRouter, turnTokenGenerator,
experimentEnrollmentManager))
experimentEnrollmentManager, cloudflareTurnCredentialsManager))
.build();
@BeforeEach
@ -97,7 +98,7 @@ class CallRoutingControllerTest {
eq(Optional.of(InetAddress.getByName(REMOTE_ADDRESS))),
anyInt())
).thenReturn(options);
try(Response response = resources.getJerseyTest()
try (Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH)
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
@ -114,10 +115,14 @@ class CallRoutingControllerTest {
}
@Test
void testGetTurnEndpointsCloudflare() {
void testGetTurnEndpointsCloudflare() throws IOException {
when(experimentEnrollmentManager.isEnrolled(AuthHelper.VALID_UUID, "cloudflareTurn"))
.thenReturn(true);
when(cloudflareTurnCredentialsManager.retrieveFromCloudflare()).thenReturn(new TurnToken("ABC", "XYZ",
List.of("turn:cloudflare.example.com:3478?transport=udp"), null,
"cf.example.com"));
try (Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH)
.request()
@ -126,11 +131,11 @@ class CallRoutingControllerTest {
assertThat(response.getStatus()).isEqualTo(200);
TurnToken token = response.readEntity(TurnToken.class);
assertThat(token.username()).isNotEmpty();
assertThat(token.password()).isNotEmpty();
assertThat(token.hostname()).isNotEmpty();
assertThat(token.username()).isEqualTo("ABC");
assertThat(token.password()).isEqualTo("XYZ");
assertThat(token.hostname()).isEqualTo("cf.example.com");
assertThat(token.urlsWithIps()).isNull();
assertThat(token.urls()).isEqualTo(List.of("turn:cf.example.com"));
assertThat(token.urls()).isEqualTo(List.of("turn:cloudflare.example.com:3478?transport=udp"));
}
}
@ -147,7 +152,7 @@ class CallRoutingControllerTest {
eq(Optional.of(InetAddress.getByName(REMOTE_ADDRESS))),
anyInt())
).thenReturn(options);
try(Response response = resources.getJerseyTest()
try (Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH)
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
@ -168,7 +173,7 @@ class CallRoutingControllerTest {
doThrow(new RateLimitExceededException(null, false))
.when(getCallEndpointLimiter).validate(AuthHelper.VALID_UUID);
try(final Response response = resources.getJerseyTest()
try (final Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH)
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))

View File

@ -124,8 +124,7 @@ currentReportingKey.secret: AAAAAAAAAAA=
currentReportingKey.salt: AAAAAAAAAAA=
turn.secret: AAAAAAAAAAA=
turn.cloudflare.username: ABCDEFGHIJKLM
turn.cloudflare.password: NOPQRSTUVWXYZ
turn.cloudflare.apiToken: ABCDEFGHIJKLM
linkDevice.secret: AAAAAAAAAAA=

View File

@ -418,10 +418,15 @@ registrationService:
turn:
secret: secret://turn.secret
cloudflare:
username: secret://turn.cloudflare.username
password: secret://turn.cloudflare.password
apiToken: secret://turn.cloudflare.apiToken
endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate
ttl: 86400
urls:
- turns:turn.cloudflare.example.com:443?transport=tcp
- turn:turn.example.com:80
urlsWithIps:
- turn:%s
- turn:%s:80?transport=tcp
- turns:%s:443?transport=tcp
hostname: turn.cloudflare.example.com
linkDevice: