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
12 changed files with 320 additions and 61 deletions

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))