From ffb81e4ff76af5b728026633aa3c3368f16e8751 Mon Sep 17 00:00:00 2001 From: Alan Liu <118789116+alanliu-signal@users.noreply.github.com> Date: Wed, 5 Jun 2024 09:03:40 -0700 Subject: [PATCH] Retrieve Cloudflare Turn Credentials from Cloudflare --- service/config/sample-secrets-bundle.yml | 3 +- service/config/sample.yml | 11 +- .../textsecuregcm/WhisperServerService.java | 39 +++++- .../CloudflareTurnCredentialsManager.java | 116 ++++++++++++++++++ .../auth/TurnTokenGenerator.java | 18 +-- .../CloudflareTurnConfiguration.java | 22 +++- .../controllers/CallRoutingController.java | 11 +- .../CloudflareTurnCredentialsManagerTest.java | 97 +++++++++++++++ .../auth/TurnTokenGeneratorTest.java | 17 +-- .../CallRoutingControllerTest.java | 33 ++--- .../resources/config/test-secrets-bundle.yml | 3 +- service/src/test/resources/config/test.yml | 11 +- 12 files changed, 320 insertions(+), 61 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManager.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java diff --git a/service/config/sample-secrets-bundle.yml b/service/config/sample-secrets-bundle.yml index 63a0bfdb6..97f2658b4 100644 --- a/service/config/sample-secrets-bundle.yml +++ b/service/config/sample-secrets-bundle.yml @@ -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= diff --git a/service/config/sample.yml b/service/config/sample.yml index 226cc3b55..f158b8750 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -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: diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index e4a835b83..e7916531c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -28,6 +28,11 @@ 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.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 void run(WhisperServerConfiguration config, Environment environment) thro .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 void run(WhisperServerConfiguration config, Environment environment) thro 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 @@ protected void configureServer(final ServerBuilder serverBuilder) { 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 @@ protected void configureServer(final ServerBuilder serverBuilder) { 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()), diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManager.java new file mode 100644 index 000000000..b71eed4cf --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManager.java @@ -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 cloudflareTurnUrls; + private final List 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 urls) { + } + } + + public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken, + final String cloudflareTurnEndpoint, final long cloudflareTurnTtl, final List cloudflareTurnUrls, + final List 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 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 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); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnTokenGenerator.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnTokenGenerator.java index bd1b2400f..ba627c0d5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnTokenGenerator.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnTokenGenerator.java @@ -17,7 +17,6 @@ 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 cloudflareTurnUrls; - private final String cloudflareTurnHostname; - public TurnTokenGenerator(final DynamicConfigurationManager 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 TurnToken generateWithTurnServerOptions(TurnServerOptions options) { 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 urlsWithIps, List urlsWithHostname) { try { final Mac mac = Mac.getInstance(ALGORITHM); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java index 040eb8a9b..0e7f593a5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java @@ -11,7 +11,25 @@ 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 urls, + @NotBlank List urlsWithIps, + @NotNull @Valid CircuitBreakerConfiguration circuitBreaker, + @NotNull @Valid RetryConfiguration retry, + @NotBlank String hostname) { + public CloudflareTurnConfiguration { + if (circuitBreaker == null) { + // It’s a little counter-intuitive, but this compact constructor allows a default value + // to be used when one isn’t specified (e.g. in YAML), allowing the field to still be + // validated as @NotNull + circuitBreaker = new CircuitBreakerConfiguration(); + } + + if (retry == null) { + retry = new RetryConfiguration(); + } + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingController.java index e042bd683..8e5144a6f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingController.java @@ -7,6 +7,7 @@ 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 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 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 address = Optional.empty(); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java new file mode 100644 index 000000000..913522ff1 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java @@ -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> 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")); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/TurnTokenGeneratorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/TurnTokenGeneratorTest.java index af95da260..543261bd8 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/TurnTokenGeneratorTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/TurnTokenGeneratorTest.java @@ -6,22 +6,15 @@ 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 void testAlwaysSelectFirst() throws JsonProcessingException { 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 void testProbabilisticUrls() throws JsonProcessingException { 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 void testExplicitEnrollment() throws JsonProcessingException { 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"); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerTest.java index 74c821c25..87e7c4602 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerTest.java @@ -16,6 +16,7 @@ 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.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 @@ void testGetTurnEndpointsSuccess() throws UnknownHostException { 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 @@ void testGetTurnEndpointsSuccess() throws UnknownHostException { } @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 @@ void testGetTurnEndpointsCloudflare() { 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 @@ void testGetTurnEndpointsInvalidIpSuccess() throws UnknownHostException { 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 @@ void testGetTurnEndpointRateLimited() throws RateLimitExceededException { 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)) diff --git a/service/src/test/resources/config/test-secrets-bundle.yml b/service/src/test/resources/config/test-secrets-bundle.yml index 1dc13ec72..7c72ee488 100644 --- a/service/src/test/resources/config/test-secrets-bundle.yml +++ b/service/src/test/resources/config/test-secrets-bundle.yml @@ -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= diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 274879b87..9bb647b21 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -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: