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= currentReportingKey.salt: AAAAAAAAAAA=
turn.secret: AAAAAAAAAAA= turn.secret: AAAAAAAAAAA=
turn.cloudflare.username: ABCDEFGHIJKLM turn.cloudflare.apiToken: ABCDEFGHIJKLM
turn.cloudflare.password: NOPQRSTUVWXYZ
linkDevice.secret: AAAAAAAAAAA= linkDevice.secret: AAAAAAAAAAA=

View File

@ -429,10 +429,15 @@ registrationService:
turn: turn:
secret: secret://turn.secret secret: secret://turn.secret
cloudflare: cloudflare:
username: secret://turn.cloudflare.username apiToken: secret://turn.cloudflare.apiToken
password: secret://turn.cloudflare.password endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate
urls: 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 hostname: turn.cloudflare.example.com
linkDevice: 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.grpc.MetricCollectingServerInterceptor;
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics; import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
import io.netty.channel.local.LocalAddress; 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.io.FileInputStream;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.security.KeyStore; 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.AccountAuthenticator;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.CertificateGenerator; import org.whispersystems.textsecuregcm.auth.CertificateGenerator;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;
import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager; import org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;
import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager; import org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;
@ -539,9 +545,24 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.workQueue(new SynchronousQueue<>()) .workQueue(new SynchronousQueue<>())
.keepAliveTime(io.dropwizard.util.Duration.seconds(60L)) .keepAliveTime(io.dropwizard.util.Duration.seconds(60L))
.build(); .build();
ExecutorService cloudflareTurnHttpExecutor = environment.lifecycle()
.executorService(name(getClass(), "cloudflareTurn-%d"))
.maxThreads(2)
.minThreads(2)
.build();
ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle() ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "subscriptionProcessorRetry-%d")).threads(1).build(); .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( ExternalServiceCredentialsGenerator directoryV2CredentialsGenerator = DirectoryV2Controller.credentialsGenerator(
config.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration()); config.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration());
@ -635,7 +656,20 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
pushLatencyManager); pushLatencyManager);
final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor); final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager, 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( final CardinalityEstimator messageByteLimitCardinalityEstimator = new CardinalityEstimator(
rateLimitersCluster, rateLimitersCluster,
@ -887,6 +921,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getNoiseWebSocketTunnelConfiguration().recognizedProxySecret().value()); config.getNoiseWebSocketTunnelConfiguration().recognizedProxySecret().value());
environment.lifecycle().manage(localEventLoopGroup); environment.lifecycle().manage(localEventLoopGroup);
environment.lifecycle().manage(dnsResolutionEventLoopGroup);
environment.lifecycle().manage(anonymousGrpcServer); environment.lifecycle().manage(anonymousGrpcServer);
environment.lifecycle().manage(authenticatedGrpcServer); environment.lifecycle().manage(authenticatedGrpcServer);
environment.lifecycle().manage(noiseWebSocketEventLoopGroup); environment.lifecycle().manage(noiseWebSocketEventLoopGroup);
@ -1018,7 +1053,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator, new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator,
experimentEnrollmentManager), experimentEnrollmentManager),
new ArchiveController(backupAuthManager, backupManager), new ArchiveController(backupAuthManager, backupManager),
new CallRoutingController(rateLimiters, callRouter, turnTokenGenerator, experimentEnrollmentManager), new CallRoutingController(rateLimiters, callRouter, turnTokenGenerator, experimentEnrollmentManager, cloudflareTurnCredentialsManager),
new CallLinkController(rateLimiters, callingGenericZkSecretParams), new CallLinkController(rateLimiters, callingGenericZkSecretParams),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(), new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(),
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()), 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.Mac;
import javax.crypto.spec.SecretKeySpec; import javax.crypto.spec.SecretKeySpec;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions; import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
import org.whispersystems.textsecuregcm.configuration.CloudflareTurnConfiguration;
import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration; import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicTurnConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicTurnConfiguration;
@ -38,21 +37,10 @@ public class TurnTokenGenerator {
private static final String WithIpsProtocol = "01"; 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, public TurnTokenGenerator(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final byte[] turnSecret, final CloudflareTurnConfiguration cloudflareTurnConfiguration) { final byte[] turnSecret) {
this.dynamicConfigurationManager = dynamicConfigurationManager; this.dynamicConfigurationManager = dynamicConfigurationManager;
this.turnSecret = turnSecret; this.turnSecret = turnSecret;
this.cloudflareTurnUsername = cloudflareTurnConfiguration.username().value();
this.cloudflareTurnPassword = cloudflareTurnConfiguration.password().value();
this.cloudflareTurnUrls = cloudflareTurnConfiguration.urls();
this.cloudflareTurnHostname = cloudflareTurnConfiguration.hostname();
} }
@Deprecated @Deprecated
@ -64,10 +52,6 @@ public class TurnTokenGenerator {
return generateToken(options.hostname(), options.urlsWithIps(), options.urlsWithHostname()); 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) { private TurnToken generateToken(String hostname, List<String> urlsWithIps, List<String> urlsWithHostname) {
try { try {
final Mac mac = Mac.getInstance(ALGORITHM); 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.NotBlank;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
public record CloudflareTurnConfiguration(@NotNull SecretString username, @NotNull SecretString password, public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
@Valid @NotNull List<@NotBlank String> urls, @NotBlank String hostname) { @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.micrometer.core.instrument.Metrics;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.io.IOException;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.util.Optional; import java.util.Optional;
@ -20,6 +21,7 @@ import javax.ws.rs.core.MediaType;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.TurnToken; import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter; import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter;
@ -40,17 +42,20 @@ public class CallRoutingController {
private final TurnCallRouter turnCallRouter; private final TurnCallRouter turnCallRouter;
private final TurnTokenGenerator tokenGenerator; private final TurnTokenGenerator tokenGenerator;
private final ExperimentEnrollmentManager experimentEnrollmentManager; private final ExperimentEnrollmentManager experimentEnrollmentManager;
private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
public CallRoutingController( public CallRoutingController(
final RateLimiters rateLimiters, final RateLimiters rateLimiters,
final TurnCallRouter turnCallRouter, final TurnCallRouter turnCallRouter,
final TurnTokenGenerator tokenGenerator, final TurnTokenGenerator tokenGenerator,
final ExperimentEnrollmentManager experimentEnrollmentManager final ExperimentEnrollmentManager experimentEnrollmentManager,
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager
) { ) {
this.rateLimiters = rateLimiters; this.rateLimiters = rateLimiters;
this.turnCallRouter = turnCallRouter; this.turnCallRouter = turnCallRouter;
this.tokenGenerator = tokenGenerator; this.tokenGenerator = tokenGenerator;
this.experimentEnrollmentManager = experimentEnrollmentManager; this.experimentEnrollmentManager = experimentEnrollmentManager;
this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;
} }
@GET @GET
@ -70,12 +75,12 @@ public class CallRoutingController {
public TurnToken getCallingRelays( public TurnToken getCallingRelays(
final @ReadOnly @Auth AuthenticatedAccount auth, final @ReadOnly @Auth AuthenticatedAccount auth,
@Context ContainerRequestContext requestContext @Context ContainerRequestContext requestContext
) throws RateLimitExceededException { ) throws RateLimitExceededException, IOException {
UUID aci = auth.getAccount().getUuid(); UUID aci = auth.getAccount().getUuid();
rateLimiters.getCallEndpointLimiter().validate(aci); rateLimiters.getCallEndpointLimiter().validate(aci);
if (experimentEnrollmentManager.isEnrolled(aci, "cloudflareTurn")) { if (experimentEnrollmentManager.isEnrolled(aci, "cloudflareTurn")) {
return tokenGenerator.generateForCloudflareBeta(); return cloudflareTurnCredentialsManager.retrieveFromCloudflare();
} }
Optional<InetAddress> address = Optional.empty(); 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 com.fasterxml.jackson.core.JsonProcessingException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.CloudflareTurnConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
public class TurnTokenGeneratorTest { 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 @Test
public void testAlwaysSelectFirst() throws JsonProcessingException { public void testAlwaysSelectFirst() throws JsonProcessingException {
final String configString = """ final String configString = """
@ -47,8 +40,7 @@ public class TurnTokenGeneratorTest {
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config); when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final TurnTokenGenerator turnTokenGenerator = final TurnTokenGenerator turnTokenGenerator =
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8), new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8));
CLOUDFLARE_TURN_CONFIGURATION);
final long COUNT = 1000; final long COUNT = 1000;
@ -88,9 +80,9 @@ public class TurnTokenGeneratorTest {
DynamicConfigurationManager.class); DynamicConfigurationManager.class);
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config); when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final TurnTokenGenerator turnTokenGenerator = final TurnTokenGenerator turnTokenGenerator =
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8), new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8));
CLOUDFLARE_TURN_CONFIGURATION);
final long COUNT = 1000; final long COUNT = 1000;
@ -133,8 +125,7 @@ public class TurnTokenGeneratorTest {
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config); when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final TurnTokenGenerator turnTokenGenerator = final TurnTokenGenerator turnTokenGenerator =
new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8), new TurnTokenGenerator(mockDynamicConfigManager, "bloop".getBytes(StandardCharsets.UTF_8));
CLOUDFLARE_TURN_CONFIGURATION);
TurnToken token = turnTokenGenerator.generate(UUID.fromString("732506d7-d04f-43a4-b1d7-8a3a91ebe8a6")); TurnToken token = turnTokenGenerator.generate(UUID.fromString("732506d7-d04f-43a4-b1d7-8a3a91ebe8a6"));
assertThat(token.urls().get(0)).isEqualTo("enrolled.org"); 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.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension; import io.dropwizard.testing.junit5.ResourceExtension;
import java.io.IOException;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets; 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.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
import org.whispersystems.textsecuregcm.auth.TurnToken; import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator; import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter; import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions; 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.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager; import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.limits.RateLimiter; import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.textsecuregcm.limits.RateLimiters;
@ -57,9 +57,10 @@ class CallRoutingControllerTest {
private static final ExperimentEnrollmentManager experimentEnrollmentManager = mock( private static final ExperimentEnrollmentManager experimentEnrollmentManager = mock(
ExperimentEnrollmentManager.class); ExperimentEnrollmentManager.class);
private static final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager, private static final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager,
"bloop".getBytes(StandardCharsets.UTF_8), "bloop".getBytes(StandardCharsets.UTF_8));
new CloudflareTurnConfiguration(new SecretString("cf_username"), new SecretString("cf_password"), private static final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = mock(
List.of("turn:cf.example.com"), "cf.example.com")); CloudflareTurnCredentialsManager.class);
private static final TurnCallRouter turnCallRouter = mock(TurnCallRouter.class); private static final TurnCallRouter turnCallRouter = mock(TurnCallRouter.class);
private static final ResourceExtension resources = ResourceExtension.builder() private static final ResourceExtension resources = ResourceExtension.builder()
@ -70,7 +71,7 @@ class CallRoutingControllerTest {
.setMapper(SystemMapper.jsonMapper()) .setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new CallRoutingController(rateLimiters, turnCallRouter, turnTokenGenerator, .addResource(new CallRoutingController(rateLimiters, turnCallRouter, turnTokenGenerator,
experimentEnrollmentManager)) experimentEnrollmentManager, cloudflareTurnCredentialsManager))
.build(); .build();
@BeforeEach @BeforeEach
@ -97,7 +98,7 @@ class CallRoutingControllerTest {
eq(Optional.of(InetAddress.getByName(REMOTE_ADDRESS))), eq(Optional.of(InetAddress.getByName(REMOTE_ADDRESS))),
anyInt()) anyInt())
).thenReturn(options); ).thenReturn(options);
try(Response response = resources.getJerseyTest() try (Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH) .target(GET_CALL_ENDPOINTS_PATH)
.request() .request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
@ -114,10 +115,14 @@ class CallRoutingControllerTest {
} }
@Test @Test
void testGetTurnEndpointsCloudflare() { void testGetTurnEndpointsCloudflare() throws IOException {
when(experimentEnrollmentManager.isEnrolled(AuthHelper.VALID_UUID, "cloudflareTurn")) when(experimentEnrollmentManager.isEnrolled(AuthHelper.VALID_UUID, "cloudflareTurn"))
.thenReturn(true); .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() try (Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH) .target(GET_CALL_ENDPOINTS_PATH)
.request() .request()
@ -126,11 +131,11 @@ class CallRoutingControllerTest {
assertThat(response.getStatus()).isEqualTo(200); assertThat(response.getStatus()).isEqualTo(200);
TurnToken token = response.readEntity(TurnToken.class); TurnToken token = response.readEntity(TurnToken.class);
assertThat(token.username()).isNotEmpty(); assertThat(token.username()).isEqualTo("ABC");
assertThat(token.password()).isNotEmpty(); assertThat(token.password()).isEqualTo("XYZ");
assertThat(token.hostname()).isNotEmpty(); assertThat(token.hostname()).isEqualTo("cf.example.com");
assertThat(token.urlsWithIps()).isNull(); 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))), eq(Optional.of(InetAddress.getByName(REMOTE_ADDRESS))),
anyInt()) anyInt())
).thenReturn(options); ).thenReturn(options);
try(Response response = resources.getJerseyTest() try (Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH) .target(GET_CALL_ENDPOINTS_PATH)
.request() .request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
@ -168,7 +173,7 @@ class CallRoutingControllerTest {
doThrow(new RateLimitExceededException(null, false)) doThrow(new RateLimitExceededException(null, false))
.when(getCallEndpointLimiter).validate(AuthHelper.VALID_UUID); .when(getCallEndpointLimiter).validate(AuthHelper.VALID_UUID);
try(final Response response = resources.getJerseyTest() try (final Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH) .target(GET_CALL_ENDPOINTS_PATH)
.request() .request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))

View File

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

View File

@ -418,10 +418,15 @@ registrationService:
turn: turn:
secret: secret://turn.secret secret: secret://turn.secret
cloudflare: cloudflare:
username: secret://turn.cloudflare.username apiToken: secret://turn.cloudflare.apiToken
password: secret://turn.cloudflare.password endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate
ttl: 86400
urls: 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 hostname: turn.cloudflare.example.com
linkDevice: linkDevice: