0
0
mirror of https://github.com/signalapp/Signal-Server.git synced 2024-09-19 19:42:18 +02:00

Add support to trial Cloudflare TURN beta

This commit is contained in:
Chris Eager 2024-04-24 18:49:53 -05:00 committed by Chris Eager
parent 0986ce12e6
commit 4a28ab6317
14 changed files with 158 additions and 58 deletions

View File

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

View File

@ -452,6 +452,11 @@ registrationService:
turn:
secret: secret://turn.secret
cloudflare:
username: secret://turn.cloudflare.username
password: secret://turn.cloudflare.password
urls:
- turns:turn.cloudflare.example.com:443?transport=tcp
linkDevice:
secret: secret://linkDevice.secret

View File

@ -57,7 +57,7 @@ import org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;
import org.whispersystems.textsecuregcm.configuration.StripeConfiguration;
import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;
import org.whispersystems.textsecuregcm.configuration.TlsKeyStoreConfiguration;
import org.whispersystems.textsecuregcm.configuration.TurnSecretConfiguration;
import org.whispersystems.textsecuregcm.configuration.TurnConfiguration;
import org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;
import org.whispersystems.textsecuregcm.configuration.VirtualThreadConfiguration;
import org.whispersystems.textsecuregcm.configuration.ZkConfig;
@ -288,7 +288,7 @@ public class WhisperServerConfiguration extends Configuration {
@Valid
@NotNull
@JsonProperty
private TurnSecretConfiguration turn;
private TurnConfiguration turn;
@Valid
@NotNull
@ -529,7 +529,7 @@ public class WhisperServerConfiguration extends Configuration {
return registrationService;
}
public TurnSecretConfiguration getTurnSecretConfiguration() {
public TurnConfiguration getTurnConfiguration() {
return turn;
}

View File

@ -605,7 +605,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
pushLatencyManager);
final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);
final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(dynamicConfigurationManager,
config.getTurnSecretConfiguration().secret().value());
config.getTurnConfiguration().secret().value(), config.getTurnConfiguration().cloudflare());
final CardinalityEstimator messageByteLimitCardinalityEstimator = new CardinalityEstimator(
rateLimitersCluster,
@ -938,7 +938,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator,
experimentEnrollmentManager),
new ArchiveController(backupAuthManager, backupManager),
new CallRoutingController(rateLimiters, callRouter, turnTokenGenerator),
new CallRoutingController(rateLimiters, callRouter, turnTokenGenerator, experimentEnrollmentManager),
new CallLinkController(rateLimiters, callingGenericZkSecretParams),
new CertificateController(new CertificateGenerator(config.getDeliveryCertificate().certificate().value(),
config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays()),

View File

@ -5,9 +5,11 @@
package org.whispersystems.textsecuregcm.auth;
import javax.annotation.Nullable;
import java.util.List;
public record TurnToken(String username, String password, List<String> urls, List<String> urlsWithIps, String hostname) {
public record TurnToken(String username, String password, List<String> urls, @Nullable List<String> urlsWithIps,
@Nullable String hostname) {
public TurnToken(String username, String password, List<String> urls) {
this(username, password, urls, null, null);
}

View File

@ -5,17 +5,6 @@
package org.whispersystems.textsecuregcm.auth;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicTurnConfiguration;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.util.WeightedRandomSelect;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
@ -25,6 +14,17 @@ import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
import org.whispersystems.textsecuregcm.configuration.CloudflareTurnConfiguration;
import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicTurnConfiguration;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.util.Pair;
import org.whispersystems.textsecuregcm.util.Util;
import org.whispersystems.textsecuregcm.util.WeightedRandomSelect;
public class TurnTokenGenerator {
@ -38,13 +38,22 @@ public class TurnTokenGenerator {
private static final String WithIpsProtocol = "01";
private final String cloudflareTurnUsername;
private final String cloudflareTurnPassword;
private final List<String> cloudflareTurnUrls;
public TurnTokenGenerator(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,
final byte[] turnSecret) {
final byte[] turnSecret, final CloudflareTurnConfiguration cloudflareTurnConfiguration) {
this.dynamicConfigurationManager = dynamicConfigurationManager;
this.turnSecret = turnSecret;
this.cloudflareTurnUsername = cloudflareTurnConfiguration.username().value();
this.cloudflareTurnPassword = cloudflareTurnConfiguration.password().value();
this.cloudflareTurnUrls = cloudflareTurnConfiguration.urls();
}
@Deprecated
public TurnToken generate(final UUID aci) {
return generateToken(null, null, urls(aci));
}
@ -53,6 +62,10 @@ public class TurnTokenGenerator {
return generateToken(options.hostname(), options.urlsWithIps(), options.urlsWithHostname());
}
public TurnToken generateForCloudflareBeta() {
return new TurnToken(cloudflareTurnUsername, cloudflareTurnPassword, cloudflareTurnUrls);
}
private TurnToken generateToken(String hostname, List<String> urlsWithIps, List<String> urlsWithHostname) {
try {
final Mac mac = Mac.getInstance(ALGORITHM);

View File

@ -0,0 +1,17 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
import java.util.List;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public record CloudflareTurnConfiguration(@NotNull SecretString username, @NotNull SecretString password,
@Valid @NotNull List<@NotBlank String> urls) {
}

View File

@ -7,5 +7,5 @@ package org.whispersystems.textsecuregcm.configuration;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;
public record TurnSecretConfiguration(SecretBytes secret) {
public record TurnConfiguration(SecretBytes secret, CloudflareTurnConfiguration cloudflare) {
}

View File

@ -95,7 +95,8 @@ public class AccountController {
this.usernameHashZkProofVerifier = usernameHashZkProofVerifier;
}
@Deprecated
// may be removed after 2024-07-16
@Deprecated(forRemoval = true)
@GET
@Path("/turn/")
@Produces(MediaType.APPLICATION_JSON)

View File

@ -1,11 +1,12 @@
package org.whispersystems.textsecuregcm.controllers;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
import io.dropwizard.auth.Auth;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Metrics;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Optional;
@ -21,14 +22,13 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.TurnToken;
import org.whispersystems.textsecuregcm.auth.TurnTokenGenerator;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
import org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;
import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.websocket.auth.ReadOnly;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
@Path("/v1/calling")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Calling")
public class CallRoutingController {
@ -39,15 +39,18 @@ public class CallRoutingController {
private final RateLimiters rateLimiters;
private final TurnCallRouter turnCallRouter;
private final TurnTokenGenerator tokenGenerator;
private final ExperimentEnrollmentManager experimentEnrollmentManager;
public CallRoutingController(
final RateLimiters rateLimiters,
final TurnCallRouter turnCallRouter,
final TurnTokenGenerator tokenGenerator
final TurnTokenGenerator tokenGenerator,
final ExperimentEnrollmentManager experimentEnrollmentManager
) {
this.rateLimiters = rateLimiters;
this.turnCallRouter = turnCallRouter;
this.tokenGenerator = tokenGenerator;
this.experimentEnrollmentManager = experimentEnrollmentManager;
}
@GET
@ -63,7 +66,7 @@ public class CallRoutingController {
@ApiResponse(responseCode = "400", description = "Invalid get call endpoint request.")
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
@ApiResponse(responseCode = "422", description = "Invalid request format.")
@ApiResponse(responseCode = "429", description = "Ratelimited.")
@ApiResponse(responseCode = "429", description = "Rate limited.")
public TurnToken getCallingRelays(
final @ReadOnly @Auth AuthenticatedAccount auth,
@Context ContainerRequestContext requestContext
@ -71,6 +74,10 @@ public class CallRoutingController {
UUID aci = auth.getAccount().getUuid();
rateLimiters.getCallEndpointLimiter().validate(aci);
if (experimentEnrollmentManager.isEnrolled(aci, "cloudflareTurn")) {
return tokenGenerator.generateForCloudflareBeta();
}
Optional<InetAddress> address = Optional.empty();
try {
final String remoteAddress = (String) requestContext.getProperty(

View File

@ -1,22 +1,27 @@
package org.whispersystems.textsecuregcm.auth;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.junit.jupiter.api.Test;
import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
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"));
@Test
public void testAlwaysSelectFirst() throws JsonProcessingException {
final String configString = """
@ -30,7 +35,7 @@ public class TurnTokenGeneratorTest {
- uris:
- never.org
weight: 0
""";
""";
DynamicConfiguration config = DynamicConfigurationManager
.parseConfiguration(configString, DynamicConfiguration.class)
.orElseThrow();
@ -42,7 +47,8 @@ public class TurnTokenGeneratorTest {
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
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;
@ -83,7 +89,8 @@ public class TurnTokenGeneratorTest {
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
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;
@ -126,7 +133,8 @@ public class TurnTokenGeneratorTest {
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
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"));
assertThat(token.urls().get(0)).isEqualTo("enrolled.org");

View File

@ -6,14 +6,13 @@
package org.whispersystems.textsecuregcm.controllers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.when;
import com.google.common.net.HttpHeaders;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
@ -24,6 +23,7 @@ import java.util.List;
import java.util.Optional;
import javax.ws.rs.core.Response;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -32,7 +32,10 @@ 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;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
@ -43,30 +46,44 @@ import org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;
@ExtendWith(DropwizardExtensionsSupport.class)
class CallRoutingControllerTest {
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
private static final RateLimiter getCallEndpointLimiter = mock(RateLimiter.class);
private static final DynamicConfigurationManager<DynamicConfiguration> configManager = mock(DynamicConfigurationManager.class);
private static final TurnTokenGenerator turnTokenGenerator = new TurnTokenGenerator(configManager, "bloop".getBytes(
StandardCharsets.UTF_8));
private static final TurnCallRouter turnCallRouter = mock(TurnCallRouter.class);
private static final String GET_CALL_ENDPOINTS_PATH = "v1/calling/relays";
private static final String REMOTE_ADDRESS = "123.123.123.1";
private static final ResourceExtension resources = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class))
.addProvider(new RateLimitExceededExceptionMapper())
.addProvider(new TestRemoteAddressFilterProvider(REMOTE_ADDRESS))
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new CallRoutingController(rateLimiters, turnCallRouter, turnTokenGenerator))
.build();
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
private static final RateLimiter getCallEndpointLimiter = mock(RateLimiter.class);
private static final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(
DynamicConfigurationManager.class);
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")));
private static final TurnCallRouter turnCallRouter = mock(TurnCallRouter.class);
private static final ResourceExtension resources = ResourceExtension.builder()
.addProvider(AuthHelper.getAuthFilter())
.addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedAccount.class))
.addProvider(new RateLimitExceededExceptionMapper())
.addProvider(new TestRemoteAddressFilterProvider(REMOTE_ADDRESS))
.setMapper(SystemMapper.jsonMapper())
.setTestContainerFactory(new GrizzlyWebTestContainerFactory())
.addResource(new CallRoutingController(rateLimiters, turnCallRouter, turnTokenGenerator,
experimentEnrollmentManager))
.build();
@BeforeEach
void setup() {
when(rateLimiters.getCallEndpointLimiter()).thenReturn(getCallEndpointLimiter);
}
@AfterEach
void tearDown() {
reset(experimentEnrollmentManager, dynamicConfigurationManager, rateLimiters, getCallEndpointLimiter,
turnCallRouter);
}
@Test
void testGetTurnEndpointsSuccess() throws UnknownHostException {
TurnServerOptions options = new TurnServerOptions(
@ -96,6 +113,27 @@ class CallRoutingControllerTest {
}
}
@Test
void testGetTurnEndpointsCloudflare() {
when(experimentEnrollmentManager.isEnrolled(AuthHelper.VALID_UUID, "cloudflareTurn"))
.thenReturn(true);
try (Response response = resources.getJerseyTest()
.target(GET_CALL_ENDPOINTS_PATH)
.request()
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
.get()) {
assertThat(response.getStatus()).isEqualTo(200);
TurnToken token = response.readEntity(TurnToken.class);
assertThat(token.username()).isNotEmpty();
assertThat(token.password()).isNotEmpty();
assertThat(token.hostname()).isNull();
assertThat(token.urlsWithIps()).isNull();
assertThat(token.urls()).isEqualTo(List.of("turn:cf.example.com"));
}
}
@Test
void testGetTurnEndpointsInvalidIpSuccess() throws UnknownHostException {
TurnServerOptions options = new TurnServerOptions(

View File

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

View File

@ -443,6 +443,11 @@ registrationService:
turn:
secret: secret://turn.secret
cloudflare:
username: secret://turn.cloudflare.username
password: secret://turn.cloudflare.password
urls:
- turns:turn.cloudflare.example.com:443?transport=tcp
linkDevice:
secret: secret://linkDevice.secret