Skip to content

Commit

Permalink
Retrieve Cloudflare Turn Credentials from Cloudflare
Browse files Browse the repository at this point in the history
  • Loading branch information
alanliu-signal committed Jun 5, 2024
1 parent 01743e5 commit ffb81e4
Show file tree
Hide file tree
Showing 12 changed files with 320 additions and 61 deletions.
3 changes: 1 addition & 2 deletions service/config/sample-secrets-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
11 changes: 8 additions & 3 deletions service/config/sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()),
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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<String> urlsWithIps, List<String> urlsWithHostname) {
try {
final Mac mac = Mac.getInstance(ALGORITHM);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> urls,
@NotBlank List<String> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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<InetAddress> address = Optional.empty();
Expand Down
Loading

0 comments on commit ffb81e4

Please sign in to comment.