Add call routing API endpoint for turn servers

This commit is contained in:
adel-signal 2024-02-09 11:12:23 -08:00 committed by GitHub
parent 4f45f23094
commit d1f68eacd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2454 additions and 4 deletions

1
.gitignore vendored
View File

@ -29,3 +29,4 @@ deployer.log
.project .project
.classpath .classpath
.settings .settings
.DS_Store

View File

@ -450,3 +450,27 @@ turn:
linkDevice: linkDevice:
secret: secret://linkDevice.secret secret: secret://linkDevice.secret
maxmindCityDatabase:
s3Region: a-region
s3Bucket: a-bucket
objectKey: an-object.tar.gz
maxSize: 32777216
callingTurnDnsRecords:
s3Region: a-region
s3Bucket: a-bucket
objectKey: an-object.tar.gz
maxSize: 32777216
callingTurnPerformanceTable:
s3Region: a-region
s3Bucket: a-bucket
objectKey: an-object.tar.gz
maxSize: 32777216
callingTurnManualTable:
s3Region: a-region
s3Bucket: a-bucket
objectKey: an-object.tar.gz
maxSize: 32777216

View File

@ -204,6 +204,17 @@
<groupId>org.apache.commons</groupId> <groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId> <artifactId>commons-csv</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.24.0</version>
</dependency>
<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>4.2.0</version>
</dependency>
<dependency> <dependency>
<groupId>com.google.firebase</groupId> <groupId>com.google.firebase</groupId>

View File

@ -38,6 +38,7 @@ import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfigurat
import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration; import org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration;
import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration;
import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;
import org.whispersystems.textsecuregcm.configuration.MonitoredS3ObjectConfiguration;
import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;
import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;
import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration; import org.whispersystems.textsecuregcm.configuration.RecaptchaConfiguration;
@ -322,11 +323,31 @@ public class WhisperServerConfiguration extends Configuration {
@JsonProperty @JsonProperty
private VirtualThreadConfiguration virtualThreadConfiguration = new VirtualThreadConfiguration(Duration.ofMillis(1)); private VirtualThreadConfiguration virtualThreadConfiguration = new VirtualThreadConfiguration(Duration.ofMillis(1));
@Valid
@NotNull
@JsonProperty
private MonitoredS3ObjectConfiguration maxmindCityDatabase;
@Valid
@NotNull
@JsonProperty
private MonitoredS3ObjectConfiguration callingTurnDnsRecords;
@Valid
@NotNull
@JsonProperty
private MonitoredS3ObjectConfiguration callingTurnPerformanceTable;
@Valid
@NotNull
@JsonProperty
private MonitoredS3ObjectConfiguration callingTurnManualTable;
public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() { public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() {
return tlsKeyStore; return tlsKeyStore;
} }
public StripeConfiguration getStripe() { public StripeConfiguration getStripe() {
return stripe; return stripe;
} }
@ -537,4 +558,20 @@ public class WhisperServerConfiguration extends Configuration {
public VirtualThreadConfiguration getVirtualThreadConfiguration() { public VirtualThreadConfiguration getVirtualThreadConfiguration() {
return virtualThreadConfiguration; return virtualThreadConfiguration;
} }
public MonitoredS3ObjectConfiguration getMaxmindCityDatabase() {
return maxmindCityDatabase;
}
public MonitoredS3ObjectConfiguration getCallingTurnDnsRecords() {
return callingTurnDnsRecords;
}
public MonitoredS3ObjectConfiguration getCallingTurnPerformanceTable() {
return callingTurnPerformanceTable;
}
public MonitoredS3ObjectConfiguration getCallingTurnManualTable() {
return callingTurnManualTable;
}
} }

View File

@ -78,6 +78,10 @@ import org.whispersystems.textsecuregcm.backup.Cdn3BackupCredentialGenerator;
import org.whispersystems.textsecuregcm.backup.Cdn3RemoteStorageManager; import org.whispersystems.textsecuregcm.backup.Cdn3RemoteStorageManager;
import org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter; import org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter;
import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator; import org.whispersystems.textsecuregcm.badges.ResourceBundleLevelTranslator;
import org.whispersystems.textsecuregcm.calls.routing.CallDnsRecordsManager;
import org.whispersystems.textsecuregcm.calls.routing.CallRoutingTableManager;
import org.whispersystems.textsecuregcm.calls.routing.DynamicConfigTurnRouter;
import org.whispersystems.textsecuregcm.calls.routing.TurnCallRouter;
import org.whispersystems.textsecuregcm.captcha.CaptchaChecker; import org.whispersystems.textsecuregcm.captcha.CaptchaChecker;
import org.whispersystems.textsecuregcm.captcha.HCaptchaClient; import org.whispersystems.textsecuregcm.captcha.HCaptchaClient;
import org.whispersystems.textsecuregcm.captcha.RecaptchaClient; import org.whispersystems.textsecuregcm.captcha.RecaptchaClient;
@ -93,6 +97,7 @@ import org.whispersystems.textsecuregcm.controllers.ArtController;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3;
import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4;
import org.whispersystems.textsecuregcm.controllers.CallRoutingController;
import org.whispersystems.textsecuregcm.controllers.CallLinkController; import org.whispersystems.textsecuregcm.controllers.CallLinkController;
import org.whispersystems.textsecuregcm.controllers.CertificateController; import org.whispersystems.textsecuregcm.controllers.CertificateController;
import org.whispersystems.textsecuregcm.controllers.ChallengeController; import org.whispersystems.textsecuregcm.controllers.ChallengeController;
@ -121,6 +126,7 @@ import org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;
import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter; import org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;
import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter; import org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;
import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter; import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
import org.whispersystems.textsecuregcm.geo.MaxMindDatabaseManager;
import org.whispersystems.textsecuregcm.grpc.AcceptLanguageInterceptor; import org.whispersystems.textsecuregcm.grpc.AcceptLanguageInterceptor;
import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService; import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService;
@ -452,6 +458,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
.scheduledExecutorService(name(getClass(), "remoteStorageRetry-%d")).threads(1).build(); .scheduledExecutorService(name(getClass(), "remoteStorageRetry-%d")).threads(1).build();
ScheduledExecutorService registrationIdentityTokenRefreshExecutor = environment.lifecycle() ScheduledExecutorService registrationIdentityTokenRefreshExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "registrationIdentityTokenRefresh-%d")).threads(1).build(); .scheduledExecutorService(name(getClass(), "registrationIdentityTokenRefresh-%d")).threads(1).build();
ScheduledExecutorService recurringConfigSyncExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "configSync-%d")).threads(1).build();
Scheduler messageDeliveryScheduler = Schedulers.fromExecutorService( Scheduler messageDeliveryScheduler = Schedulers.fromExecutorService(
ExecutorServiceMetrics.monitor(Metrics.globalRegistry, ExecutorServiceMetrics.monitor(Metrics.globalRegistry,
@ -707,6 +715,39 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getClientCdnConfiguration().getAttachmentUrls(), config.getClientCdnConfiguration().getAttachmentUrls(),
clock); clock);
final DynamicConfigTurnRouter configTurnRouter = new DynamicConfigTurnRouter(dynamicConfigurationManager);
MaxMindDatabaseManager geoIpCountryDatabaseManager = new MaxMindDatabaseManager(
recurringConfigSyncExecutor,
config.getMaxmindCityDatabase(),
"country"
);
CallDnsRecordsManager callDnsRecordsManager = new CallDnsRecordsManager(
recurringConfigSyncExecutor,
config.getCallingTurnDnsRecords()
);
CallRoutingTableManager callRoutingTableManager = new CallRoutingTableManager(
recurringConfigSyncExecutor,
config.getCallingTurnPerformanceTable(),
"Performance"
);
CallRoutingTableManager manualCallRoutingTableManager = new CallRoutingTableManager(
recurringConfigSyncExecutor,
config.getCallingTurnManualTable(),
"Manual"
);
TurnCallRouter callRouter = new TurnCallRouter(
callDnsRecordsManager,
callRoutingTableManager,
manualCallRoutingTableManager,
configTurnRouter,
geoIpCountryDatabaseManager
);
final BasicCredentialAuthenticationInterceptor basicCredentialAuthenticationInterceptor = final BasicCredentialAuthenticationInterceptor basicCredentialAuthenticationInterceptor =
new BasicCredentialAuthenticationInterceptor(new AccountAuthenticator(accountsManager)); new BasicCredentialAuthenticationInterceptor(new AccountAuthenticator(accountsManager));
@ -834,6 +875,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, new TusAttachmentGenerator(config.getTus()), new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, new TusAttachmentGenerator(config.getTus()),
experimentEnrollmentManager), experimentEnrollmentManager),
new ArchiveController(backupAuthManager, backupManager), new ArchiveController(backupAuthManager, backupManager),
new CallRoutingController(rateLimiters, callRouter, turnTokenGenerator),
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

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

View File

@ -5,6 +5,7 @@
package org.whispersystems.textsecuregcm.auth; package org.whispersystems.textsecuregcm.auth;
import org.whispersystems.textsecuregcm.calls.routing.TurnServerOptions;
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;
@ -41,8 +42,15 @@ public class TurnTokenGenerator {
} }
public TurnToken generate(final UUID aci) { public TurnToken generate(final UUID aci) {
return generateToken(null, null, urls(aci));
}
public TurnToken generateWithTurnServerOptions(TurnServerOptions options) {
return generateToken(options.hostname(), options.urlsWithIps(), options.urlsWithHostname());
}
private TurnToken generateToken(String hostname, List<String> urlsWithIps, List<String> urlsWithHostname) {
try { try {
final List<String> urls = urls(aci);
final Mac mac = Mac.getInstance(ALGORITHM); final Mac mac = Mac.getInstance(ALGORITHM);
final long validUntilSeconds = Instant.now().plus(Duration.ofDays(1)).getEpochSecond(); final long validUntilSeconds = Instant.now().plus(Duration.ofDays(1)).getEpochSecond();
final long user = Util.ensureNonNegativeInt(new SecureRandom().nextInt()); final long user = Util.ensureNonNegativeInt(new SecureRandom().nextInt());
@ -51,7 +59,7 @@ public class TurnTokenGenerator {
mac.init(new SecretKeySpec(turnSecret, ALGORITHM)); mac.init(new SecretKeySpec(turnSecret, ALGORITHM));
final String password = Base64.getEncoder().encodeToString(mac.doFinal(userTime.getBytes())); final String password = Base64.getEncoder().encodeToString(mac.doFinal(userTime.getBytes()));
return new TurnToken(userTime, password, urls); return new TurnToken(userTime, password, urlsWithHostname, urlsWithIps, hostname);
} catch (final NoSuchAlgorithmException | InvalidKeyException e) { } catch (final NoSuchAlgorithmException | InvalidKeyException e) {
throw new AssertionError(e); throw new AssertionError(e);
} }

View File

@ -0,0 +1,34 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import javax.validation.constraints.NotNull;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
public record CallDnsRecords(
@NotNull
Map<String, List<InetAddress>> aByRegion,
@NotNull
Map<String, List<InetAddress>> aaaaByRegion
) {
public String getSummary() {
int numARecords = aByRegion.values().stream().mapToInt(List::size).sum();
int numAAAARecords = aaaaByRegion.values().stream().mapToInt(List::size).sum();
return String.format(
"(A records, %s regions, %s records), (AAAA records, %s regions, %s records)",
aByRegion.size(),
numARecords,
aaaaByRegion.size(),
numAAAARecords
);
}
public static CallDnsRecords empty() {
return new CallDnsRecords(Map.of(), Map.of());
}
}

View File

@ -0,0 +1,92 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import io.dropwizard.lifecycle.Managed;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import org.whispersystems.textsecuregcm.configuration.MonitoredS3ObjectConfiguration;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
public class CallDnsRecordsManager implements Supplier<CallDnsRecords>, Managed {
private final S3ObjectMonitor objectMonitor;
private final AtomicReference<CallDnsRecords> callDnsRecords = new AtomicReference<>();
private final Timer refreshTimer;
private static final Logger log = LoggerFactory.getLogger(CallDnsRecordsManager.class);
private static final ObjectMapper objectMapper = JsonMapper.builder()
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
.build();
public CallDnsRecordsManager(
@Nonnull final ScheduledExecutorService executorService,
@Nonnull final MonitoredS3ObjectConfiguration configuration
){
this.objectMonitor = new S3ObjectMonitor(
configuration.s3Region(),
configuration.s3Bucket(),
configuration.objectKey(),
configuration.maxSize(),
executorService,
configuration.refreshInterval(),
this::handleDatabaseChanged
);
this.callDnsRecords.set(CallDnsRecords.empty());
this.refreshTimer = Metrics.timer(MetricsUtil.name(CallDnsRecordsManager.class), "refresh");
}
private void handleDatabaseChanged(final InputStream inputStream) {
refreshTimer.record(() -> {
try (final InputStream bufferedInputStream = new BufferedInputStream(inputStream)) {
final CallDnsRecords newRecords = parseRecords(bufferedInputStream);
final CallDnsRecords oldRecords = callDnsRecords.getAndSet(newRecords);
log.info("Replaced dns records, old summary=[{}], new summary=[{}]", oldRecords != null ? oldRecords.getSummary() : "null", newRecords);
} catch (final IOException e) {
log.error("Failed to load Call DNS Records");
}
});
}
static CallDnsRecords parseRecords(InputStream inputStream) throws IOException {
return objectMapper.readValue(inputStream, CallDnsRecords.class);
}
@Override
public void start() throws Exception {
Managed.super.start();
objectMonitor.start();
}
@Override
public void stop() throws Exception {
objectMonitor.stop();
Managed.super.stop();
callDnsRecords.getAndSet(null);
}
@Override
public CallDnsRecords get() {
return this.callDnsRecords.get();
}
}

View File

@ -0,0 +1,193 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.math.BigInteger;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.function.Function;
import java.util.stream.Stream;
public class CallRoutingTable {
private final TreeMap<Integer, Map<Integer, List<String>>> ipv4Map;
private final TreeMap<Integer, Map<BigInteger, List<String>>> ipv6Map;
private final Map<GeoKey, List<String>> geoToDatacenter;
public CallRoutingTable(
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4SubnetToDatacenter,
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6SubnetToDatacenter,
Map<GeoKey, List<String>> geoToDatacenter
) {
this.ipv4Map = new TreeMap<>();
for (Map.Entry<CidrBlock.IpV4CidrBlock, List<String>> t : ipv4SubnetToDatacenter.entrySet()) {
if (!this.ipv4Map.containsKey(t.getKey().cidrBlockSize())) {
this.ipv4Map.put(t.getKey().cidrBlockSize(), new HashMap<>());
}
this.ipv4Map
.get(t.getKey().cidrBlockSize())
.put(t.getKey().subnet(), t.getValue());
}
this.ipv6Map = new TreeMap<>();
for (Map.Entry<CidrBlock.IpV6CidrBlock, List<String>> t : ipv6SubnetToDatacenter.entrySet()) {
if (!this.ipv6Map.containsKey(t.getKey().cidrBlockSize())) {
this.ipv6Map.put(t.getKey().cidrBlockSize(), new HashMap<>());
}
this.ipv6Map
.get(t.getKey().cidrBlockSize())
.put(t.getKey().subnet(), t.getValue());
}
this.geoToDatacenter = geoToDatacenter;
}
public static CallRoutingTable empty() {
return new CallRoutingTable(Map.of(), Map.of(), Map.of());
}
public enum Protocol {
v4,
v6
}
public record GeoKey(
@NotBlank String continent,
@NotBlank String country,
@NotNull Optional<String> subdivision,
@NotBlank Protocol protocol
) {}
/**
* Returns ordered list of fastest datacenters based on IP & Geo info. Prioritize the results based on subnet.
* Returns at most three, 2 by subnet and 1 by geo. Takes more from either bucket to hit 3.
*/
public List<String> getDatacentersFor(
InetAddress address,
String continent,
String country,
Optional<String> subdivision
) {
final int NUM_DATACENTERS = 3;
if(this.isEmpty()) {
return Collections.emptyList();
}
List<String> dcsBySubnet = getDatacentersBySubnet(address);
List<String> dcsByGeo = getDatacentersByGeo(continent, country, subdivision).stream()
.limit(NUM_DATACENTERS)
.filter(dc ->
(dcsBySubnet.isEmpty() || !dc.equals(dcsBySubnet.getFirst()))
&& (dcsBySubnet.size() < 2 || !dc.equals(dcsBySubnet.get(1)))
).toList();
return Stream.concat(
dcsBySubnet.stream().limit(dcsByGeo.isEmpty() ? NUM_DATACENTERS : NUM_DATACENTERS - 1),
dcsByGeo.stream())
.limit(NUM_DATACENTERS)
.toList();
}
public boolean isEmpty() {
return this.ipv4Map.isEmpty() && this.ipv6Map.isEmpty() && this.geoToDatacenter.isEmpty();
}
/**
* Returns ordered list of fastest datacenters based on ip info. Prioritizes V4 connections.
*/
public List<String> getDatacentersBySubnet(InetAddress address) throws IllegalArgumentException {
if(address instanceof Inet4Address) {
for(Map.Entry<Integer, Map<Integer, List<String>>> t: this.ipv4Map.descendingMap().entrySet()) {
int maskedIp = CidrBlock.IpV4CidrBlock.maskToSize((Inet4Address) address, t.getKey());
if(t.getValue().containsKey(maskedIp)) {
return t.getValue().get(maskedIp);
}
}
} else if (address instanceof Inet6Address) {
for(Map.Entry<Integer, Map<BigInteger, List<String>>> t: this.ipv6Map.descendingMap().entrySet()) {
BigInteger maskedIp = CidrBlock.IpV6CidrBlock.maskToSize((Inet6Address) address, t.getKey());
if(t.getValue().containsKey(maskedIp)) {
return t.getValue().get(maskedIp);
}
}
} else {
throw new IllegalArgumentException("Expected either an Inet4Address or Inet6Address");
}
return Collections.emptyList();
}
/**
* Returns ordered list of fastest datacenters based on geo info. Attempts to match based on subdivision, falls back
* to country based lookup. Does not attempt to look for nearby subdivisions. Prioritizes V4 connections.
*/
public List<String> getDatacentersByGeo(
String continent,
String country,
Optional<String> subdivision
) {
GeoKey v4Key = new GeoKey(continent, country, subdivision, Protocol.v4);
List<String> v4Options = this.geoToDatacenter.getOrDefault(v4Key, Collections.emptyList());
List<String> v4OptionsBackup = v4Options.isEmpty() && subdivision.isPresent() ?
this.geoToDatacenter.getOrDefault(
new GeoKey(continent, country, Optional.empty(), Protocol.v4),
Collections.emptyList())
: Collections.emptyList();
GeoKey v6Key = new GeoKey(continent, country, subdivision, Protocol.v6);
List<String> v6Options = this.geoToDatacenter.getOrDefault(v6Key, Collections.emptyList());
List<String> v6OptionsBackup = v6Options.isEmpty() && subdivision.isPresent() ?
this.geoToDatacenter.getOrDefault(
new GeoKey(continent, country, Optional.empty(), Protocol.v6),
Collections.emptyList())
: Collections.emptyList();
return Stream.of(
v4Options.stream(),
v6Options.stream(),
v4OptionsBackup.stream(),
v6OptionsBackup.stream()
)
.flatMap(Function.identity())
.distinct()
.toList();
}
public String toSummaryString() {
return String.format(
"[Ipv4Table=%s rows, Ipv6Table=%s rows, GeoTable=%s rows]",
ipv4Map.size(),
ipv6Map.size(),
geoToDatacenter.size()
);
}
@Override
public boolean equals(final Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
CallRoutingTable that = (CallRoutingTable) o;
return Objects.equals(ipv4Map, that.ipv4Map) && Objects.equals(ipv6Map, that.ipv6Map) && Objects.equals(
geoToDatacenter, that.geoToDatacenter);
}
@Override
public int hashCode() {
return Objects.hash(ipv4Map, ipv6Map, geoToDatacenter);
}
}

View File

@ -0,0 +1,85 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import io.dropwizard.lifecycle.Managed;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.configuration.MonitoredS3ObjectConfiguration;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
public class CallRoutingTableManager implements Supplier<CallRoutingTable>, Managed {
private final S3ObjectMonitor objectMonitor;
private final AtomicReference<CallRoutingTable> routingTable = new AtomicReference<>();
private final String tableTag;
private final Timer refreshTimer;
private static final Logger log = LoggerFactory.getLogger(CallRoutingTableManager.class);
public CallRoutingTableManager(
@Nonnull final ScheduledExecutorService executorService,
@Nonnull final MonitoredS3ObjectConfiguration configuration,
@Nonnull final String tableTag
){
this.objectMonitor = new S3ObjectMonitor(
configuration.s3Region(),
configuration.s3Bucket(),
configuration.objectKey(),
configuration.maxSize(),
executorService,
configuration.refreshInterval(),
this::handleDatabaseChanged
);
this.tableTag = tableTag;
this.routingTable.set(CallRoutingTable.empty());
this.refreshTimer = Metrics.timer(MetricsUtil.name(CallRoutingTableManager.class), tableTag);
}
private void handleDatabaseChanged(final InputStream inputStream) {
refreshTimer.record(() -> {
try(InputStreamReader reader = new InputStreamReader(inputStream)) {
CallRoutingTable newTable = CallRoutingTableParser.fromJson(reader);
this.routingTable.set(newTable);
log.info("Replaced {} call routing table: {}", tableTag, newTable.toSummaryString());
} catch (final IOException e) {
log.error("Failed to parse and update {} call routing table", tableTag);
}
});
}
@Override
public void start() throws Exception {
Managed.super.start();
objectMonitor.start();
}
@Override
public void stop() throws Exception {
Managed.super.stop();
objectMonitor.stop();
routingTable.getAndSet(null);
}
@Override
public CallRoutingTable get() {
return this.routingTable.get();
}
}

View File

@ -0,0 +1,185 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
final class CallRoutingTableParser {
private final static int IPV4_DEFAULT_BLOCK_SIZE = 24;
private final static int IPV6_DEFAULT_BLOCK_SIZE = 48;
private static final ObjectMapper objectMapper = JsonMapper.builder()
.enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION)
.build();
/** Used for parsing JSON */
private static class RawCallRoutingTable {
public Map<String, List<String>> ipv4GeoToDataCenters = Map.of();
public Map<String, List<String>> ipv6GeoToDataCenters = Map.of();
public Map<String, List<String>> ipv4SubnetsToDatacenters = Map.of();
public Map<String, List<String>> ipv6SubnetsToDatacenters = Map.of();
}
private final static String WHITESPACE_REGEX = "\\s+";
public static CallRoutingTable fromJson(final Reader inputReader) throws IOException {
try (final BufferedReader reader = new BufferedReader(inputReader)) {
RawCallRoutingTable rawTable = objectMapper.readValue(reader, RawCallRoutingTable.class);
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4SubnetToDatacenter = rawTable.ipv4SubnetsToDatacenters
.entrySet()
.stream()
.collect(Collectors.toUnmodifiableMap(
e -> (CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock(e.getKey(), IPV4_DEFAULT_BLOCK_SIZE),
Map.Entry::getValue
));
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6SubnetToDatacenter = rawTable.ipv6SubnetsToDatacenters
.entrySet()
.stream()
.collect(Collectors.toUnmodifiableMap(
e -> (CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock(e.getKey(), IPV6_DEFAULT_BLOCK_SIZE),
Map.Entry::getValue
));
Map<CallRoutingTable.GeoKey, List<String>> geoToDatacenter = Stream.concat(
rawTable.ipv4GeoToDataCenters
.entrySet()
.stream()
.map(e -> Map.entry(parseRawGeoKey(e.getKey(), CallRoutingTable.Protocol.v4), e.getValue())),
rawTable.ipv6GeoToDataCenters
.entrySet()
.stream()
.map(e -> Map.entry(parseRawGeoKey(e.getKey(), CallRoutingTable.Protocol.v6), e.getValue()))
).collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));
return new CallRoutingTable(
ipv4SubnetToDatacenter,
ipv6SubnetToDatacenter,
geoToDatacenter
);
}
}
private static CallRoutingTable.GeoKey parseRawGeoKey(String rawKey, CallRoutingTable.Protocol protocol) {
String[] splits = rawKey.split("-");
if (splits.length < 2 || splits.length > 3) {
throw new IllegalArgumentException("Invalid raw key");
}
Optional<String> subdivision = splits.length < 3 ? Optional.empty() : Optional.of(splits[2]);
return new CallRoutingTable.GeoKey(splits[0], splits[1], subdivision, protocol);
}
/**
* Parses a call routing table in TSV format. Example below - see tests for more examples:
192.0.2.0/24 northamerica-northeast1
198.51.100.0/24 us-south1
203.0.113.0/24 asia-southeast1
2001:db8:b0a9::/48 us-east4
2001:db8:b0f5::/48 us-central1 northamerica-northeast1 us-east4
2001:db8:9406::/48 us-east1 us-central1
SA-SR-v4 us-east1 us-east4
SA-SR-v6 us-east1 us-south1
SA-UY-v4 southamerica-west1 southamerica-east1 europe-west3
SA-UY-v6 southamerica-west1 europe-west4
SA-VE-v4 us-east1 us-east4 us-south1
SA-VE-v6 us-east1 northamerica-northeast1 us-east4
ZZ-ZZ-v4 asia-south1 europe-southwest1 australia-southeast1
*/
public static CallRoutingTable fromTsv(final Reader inputReader) throws IOException {
try (final BufferedReader reader = new BufferedReader(inputReader)) {
// use maps to silently dedupe CidrBlocks
Map<CidrBlock.IpV4CidrBlock, List<String>> ipv4Map = new HashMap<>();
Map<CidrBlock.IpV6CidrBlock, List<String>> ipv6Map = new HashMap<>();
Map<CallRoutingTable.GeoKey, List<String>> ipGeoTable = new HashMap<>();
String line;
while((line = reader.readLine()) != null) {
if(line.isBlank()) {
continue;
}
List<String> splits = Arrays.stream(line.split(WHITESPACE_REGEX)).filter(s -> !s.isBlank()).toList();
if (splits.size() < 2) {
throw new IllegalStateException("Invalid row, expected some key and list of values");
}
List<String> datacenters = splits.subList(1, splits.size());
switch (guessLineType(splits)) {
case v4 -> {
CidrBlock cidrBlock = CidrBlock.parseCidrBlock(splits.getFirst());
if(!(cidrBlock instanceof CidrBlock.IpV4CidrBlock)) {
throw new IllegalArgumentException("Expected an ipv4 cidr block");
}
ipv4Map.put((CidrBlock.IpV4CidrBlock) cidrBlock, datacenters);
}
case v6 -> {
CidrBlock cidrBlock = CidrBlock.parseCidrBlock(splits.getFirst());
if(!(cidrBlock instanceof CidrBlock.IpV6CidrBlock)) {
throw new IllegalArgumentException("Expected an ipv6 cidr block");
}
ipv6Map.put((CidrBlock.IpV6CidrBlock) cidrBlock, datacenters);
}
case Geo -> {
String[] geo = splits.getFirst().split("-");
if(geo.length < 3) {
throw new IllegalStateException("Geo row key invalid, expected atleast continent, country, and protocol");
}
String continent = geo[0];
String country = geo[1];
Optional<String> subdivision = geo.length > 3 ? Optional.of(geo[2]) : Optional.empty();
CallRoutingTable.Protocol protocol = CallRoutingTable.Protocol.valueOf(geo[geo.length - 1].toLowerCase());
CallRoutingTable.GeoKey tableKey = new CallRoutingTable.GeoKey(
continent,
country,
subdivision,
protocol
);
ipGeoTable.put(tableKey, datacenters);
}
}
}
return new CallRoutingTable(
ipv4Map,
ipv6Map,
ipGeoTable
);
}
}
private static LineType guessLineType(List<String> splits) {
String first = splits.getFirst();
if (first.contains("-")) {
return LineType.Geo;
} else if(first.contains(":")) {
return LineType.v6;
} else if (first.contains(".")) {
return LineType.v4;
}
throw new IllegalArgumentException(String.format("Invalid line, could not determine type from '%s'", first));
}
private enum LineType {
v4, v6, Geo
}
}

View File

@ -0,0 +1,137 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import java.math.BigInteger;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
/**
* Can be used to check if an IP is in the CIDR block
*/
public interface CidrBlock {
boolean ipInBlock(InetAddress address);
static CidrBlock parseCidrBlock(String cidrBlock, int defaultBlockSize) {
String[] splits = cidrBlock.split("/");
if(splits.length > 2) {
throw new IllegalArgumentException("Invalid cidr block format, expected {address}/{blocksize}");
}
try {
int blockSize = splits.length == 2 ? Integer.parseInt(splits[1]) : defaultBlockSize;
return parseCidrBlockInner(splits[0], blockSize);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format("Invalid block size specified: '%s'", splits[1]));
}
}
static CidrBlock parseCidrBlock(String cidrBlock) {
String[] splits = cidrBlock.split("/");
if (splits.length != 2) {
throw new IllegalArgumentException("Invalid cidr block format, expected {address}/{blocksize}");
}
try {
int blockSize = Integer.parseInt(splits[1]);
return parseCidrBlockInner(splits[0], blockSize);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(String.format("Invalid block size specified: '%s'", splits[1]));
}
}
private static CidrBlock parseCidrBlockInner(String rawAddress, int blockSize) {
try {
InetAddress address = InetAddress.getByName(rawAddress);
if(address instanceof Inet4Address) {
return IpV4CidrBlock.of((Inet4Address) address, blockSize);
} else if (address instanceof Inet6Address) {
return IpV6CidrBlock.of((Inet6Address) address, blockSize);
} else {
throw new IllegalArgumentException("Must be an ipv4 or ipv6 string");
}
} catch (UnknownHostException e) {
throw new IllegalArgumentException(e);
}
}
record IpV4CidrBlock(int subnet, int subnetMask, int cidrBlockSize) implements CidrBlock {
public static IpV4CidrBlock of(Inet4Address subnet, int cidrBlockSize) {
if(cidrBlockSize > 32 || cidrBlockSize < 0) {
throw new IllegalArgumentException("Invalid cidrBlockSize");
}
int subnetMask = mask(cidrBlockSize);
int maskedIp = ipToInt(subnet) & subnetMask;
return new IpV4CidrBlock(maskedIp, subnetMask, cidrBlockSize);
}
public boolean ipInBlock(InetAddress address) {
if(!(address instanceof Inet4Address)) {
return false;
}
int ip = ipToInt((Inet4Address) address);
return (ip & subnetMask) == subnet;
}
private static int ipToInt(Inet4Address address) {
byte[] octets = address.getAddress();
return (octets[0] & 0xff) << 24 |
(octets[1] & 0xff) << 16 |
(octets[2] & 0xff) << 8 |
octets[3] & 0xff;
}
private static int mask(int cidrBlockSize) {
return (int) (-1L << (32 - cidrBlockSize));
}
public static int maskToSize(Inet4Address address, int cidrBlockSize) {
return ipToInt(address) & mask(cidrBlockSize);
}
}
record IpV6CidrBlock(BigInteger subnet, BigInteger subnetMask, int cidrBlockSize) implements CidrBlock {
private static final BigInteger MINUS_ONE = BigInteger.valueOf(-1);
public static IpV6CidrBlock of(Inet6Address subnet, int cidrBlockSize) {
if(cidrBlockSize > 128 || cidrBlockSize < 0) {
throw new IllegalArgumentException("Invalid cidrBlockSize");
}
BigInteger subnetMask = mask(cidrBlockSize);
BigInteger maskedIp = ipToInt(subnet).and(subnetMask);
return new IpV6CidrBlock(maskedIp, subnetMask, cidrBlockSize);
}
public boolean ipInBlock(InetAddress address) {
if(!(address instanceof Inet6Address)) {
return false;
}
BigInteger ip = ipToInt((Inet6Address) address);
return ip.and(subnetMask).equals(subnet);
}
private static BigInteger ipToInt(Inet6Address ipAddress) {
byte[] octets = ipAddress.getAddress();
assert octets.length == 16;
return new BigInteger(octets);
}
private static BigInteger mask(int cidrBlockSize) {
return MINUS_ONE.shiftLeft(128 - cidrBlockSize);
}
public static BigInteger maskToSize(Inet6Address address, int cidrBlockSize) {
return ipToInt(address).and(mask(cidrBlockSize));
}
}
}

View File

@ -0,0 +1,69 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
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.WeightedRandomSelect;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
/** Uses DynamicConfig to help route a turn request */
public class DynamicConfigTurnRouter {
private static final Random rng = new Random();
public static final long RANDOMIZE_RATE_BASIS = 100_000;
private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;
public DynamicConfigTurnRouter(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {
this.dynamicConfigurationManager = dynamicConfigurationManager;
}
public List<String> targetedUrls(final UUID aci) {
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
final Optional<TurnUriConfiguration> enrolled = turnConfig.getUriConfigs().stream()
.filter(config -> config.getEnrolledAcis().contains(aci))
.findFirst();
return enrolled
.map(turnUriConfiguration -> turnUriConfiguration.getUris().stream().toList())
.orElse(Collections.emptyList());
}
public List<String> randomUrls() {
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
// select from turn server sets by weighted choice
return WeightedRandomSelect.select(turnConfig
.getUriConfigs()
.stream()
.map(c -> new Pair<>(c.getUris(), c.getWeight())).toList());
}
public String getHostname() {
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
return turnConfig.getHostname();
}
public long getRandomizeRate() {
final DynamicTurnConfiguration turnConfig = dynamicConfigurationManager.getConfiguration().getTurnConfiguration();
return turnConfig.getRandomizeRate();
}
public boolean shouldRandomize() {
long rate = getRandomizeRate();
return rate >= RANDOMIZE_RATE_BASIS || rng.nextLong(0, DynamicConfigTurnRouter.RANDOMIZE_RATE_BASIS) < rate;
}
}

View File

@ -0,0 +1,149 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.exception.GeoIp2Exception;
import com.maxmind.geoip2.model.CityResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.textsecuregcm.util.Util;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.net.InetAddress;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.Stream;
/**
* Returns routes based on performance tables, manually routing tables, and target routing. Falls back to a random Turn
* instance that the server knows about.
*/
public class TurnCallRouter {
private final Logger logger = LoggerFactory.getLogger(TurnCallRouter.class);
private final Supplier<CallDnsRecords> callDnsRecords;
private final Supplier<CallRoutingTable> performanceRouting;
private final Supplier<CallRoutingTable> manualRouting;
private final DynamicConfigTurnRouter configTurnRouter;
private final Supplier<DatabaseReader> geoIp;
public TurnCallRouter(
@Nonnull Supplier<CallDnsRecords> callDnsRecords,
@Nonnull Supplier<CallRoutingTable> performanceRouting,
@Nonnull Supplier<CallRoutingTable> manualRouting,
@Nonnull DynamicConfigTurnRouter configTurnRouter,
@Nonnull Supplier<DatabaseReader> geoIp
) {
this.performanceRouting = performanceRouting;
this.callDnsRecords = callDnsRecords;
this.manualRouting = manualRouting;
this.configTurnRouter = configTurnRouter;
this.geoIp = geoIp;
}
/**
* Gets Turn Instance addresses. Returns both the IPv4 and IPv6 addresses. Prioritizes V4 connections.
* @param aci aci of client
* @param clientAddress IP address to base routing on
* @param instanceLimit max instances to return options for
*/
public TurnServerOptions getRoutingFor(
@Nonnull final UUID aci,
@Nonnull final Optional<InetAddress> clientAddress,
final int instanceLimit
) {
try {
return getRoutingForInner(aci, clientAddress, instanceLimit);
} catch(Exception e) {
logger.error("Failed to perform routing", e);
return new TurnServerOptions(this.configTurnRouter.getHostname(), null, this.configTurnRouter.randomUrls());
}
}
TurnServerOptions getRoutingForInner(
@Nonnull final UUID aci,
@Nonnull final Optional<InetAddress> clientAddress,
final int instanceLimit
) {
if (instanceLimit < 1) {
throw new IllegalArgumentException("Limit cannot be less than one");
}
String hostname = this.configTurnRouter.getHostname();
List<String> targetedUrls = this.configTurnRouter.targetedUrls(aci);
if(!targetedUrls.isEmpty()) {
return new TurnServerOptions(hostname, null, targetedUrls);
}
if(clientAddress.isEmpty() || this.configTurnRouter.shouldRandomize()) {
return new TurnServerOptions(hostname, null, this.configTurnRouter.randomUrls());
}
CityResponse geoInfo;
try {
geoInfo = geoIp.get().city(clientAddress.get());
} catch (IOException | GeoIp2Exception e) {
throw new RuntimeException(e);
}
Optional<String> subdivision = !geoInfo.getSubdivisions().isEmpty()
? Optional.of(geoInfo.getSubdivisions().getFirst().getIsoCode())
: Optional.empty();
List<String> datacenters = this.manualRouting.get().getDatacentersFor(
clientAddress.get(),
geoInfo.getContinent().getCode(),
geoInfo.getCountry().getIsoCode(),
subdivision
);
if (datacenters.isEmpty()){
datacenters = this.performanceRouting.get().getDatacentersFor(
clientAddress.get(),
geoInfo.getContinent().getCode(),
geoInfo.getCountry().getIsoCode(),
subdivision
);
}
List<String> urlsWithIps = getUrlsForInstances(selectInstances(datacenters, instanceLimit));
return new TurnServerOptions(hostname, urlsWithIps, this.configTurnRouter.randomUrls());
}
private List<String> selectInstances(List<String> datacenters, int limit) {
if(datacenters.isEmpty() || limit == 0) {
return Collections.emptyList();
}
CallDnsRecords dnsRecords = this.callDnsRecords.get();
List<InetAddress> ipv4Selection = datacenters.stream()
.flatMap(dc -> Util.randomNOfStable(dnsRecords.aByRegion().get(dc), 2).stream())
.toList();
List<InetAddress> ipv6Selection = datacenters.stream()
.flatMap(dc -> Util.randomNOfStable(dnsRecords.aaaaByRegion().get(dc), 2).stream())
.toList();
if (ipv4Selection.size() < ipv6Selection.size()) {
ipv4Selection = ipv4Selection.stream().limit(limit / 2).toList();
ipv6Selection = ipv6Selection.stream().limit(limit - ipv4Selection.size()).toList();
} else {
ipv6Selection = ipv6Selection.stream().limit(limit / 2).toList();
ipv4Selection = ipv4Selection.stream().limit(limit - ipv6Selection.size()).toList();
}
return Stream.concat(ipv4Selection.stream(), ipv6Selection.stream()).map(InetAddress::getHostAddress).toList();
}
private static List<String> getUrlsForInstances(List<String> instanceIps) {
return instanceIps.stream().flatMap(ip -> Stream.of(
String.format("stun:%s", ip),
String.format("turn:%s", ip),
String.format("turn:%s:80?transport=tcp", ip),
String.format("turns:%s:443?transport=tcp", ip)
)
).toList();
}
}

View File

@ -0,0 +1,11 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import java.util.List;
public record TurnServerOptions(String hostname, List<String> urlsWithIps, List<String> urlsWithHostname) {
}

View File

@ -13,10 +13,28 @@ import org.whispersystems.textsecuregcm.configuration.TurnUriConfiguration;
public class DynamicTurnConfiguration { public class DynamicTurnConfiguration {
@JsonProperty
private String hostname;
/**
* Rate at which to prioritize a random turn URL to exercise all endpoints.
* Based on a 100,000 basis, where 100,000 == 100%.
*/
@JsonProperty
private long randomizeRate = 5_000;
@JsonProperty @JsonProperty
private List<@Valid TurnUriConfiguration> uriConfigs = Collections.emptyList(); private List<@Valid TurnUriConfiguration> uriConfigs = Collections.emptyList();
public List<TurnUriConfiguration> getUriConfigs() { public List<TurnUriConfiguration> getUriConfigs() {
return uriConfigs; return uriConfigs;
} }
public long getRandomizeRate() {
return randomizeRate;
}
public String getHostname() {
return hostname;
}
} }

View File

@ -93,6 +93,7 @@ public class AccountController {
this.usernameHashZkProofVerifier = usernameHashZkProofVerifier; this.usernameHashZkProofVerifier = usernameHashZkProofVerifier;
} }
@Deprecated
@GET @GET
@Path("/turn/") @Path("/turn/")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)

View File

@ -0,0 +1,85 @@
package org.whispersystems.textsecuregcm.controllers;
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;
import java.util.UUID;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import org.slf4j.Logger;
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.filters.RemoteAddressFilter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;
@Path("/v1/calling")
@io.swagger.v3.oas.annotations.tags.Tag(name = "Calling")
public class CallRoutingController {
private static final int TURN_INSTANCE_LIMIT = 6;
private static final Counter INVALID_IP_COUNTER = Metrics.counter(name(CallRoutingController.class, "invalidIP"));
private static final Logger log = LoggerFactory.getLogger(CallRoutingController.class);
private final RateLimiters rateLimiters;
private final TurnCallRouter turnCallRouter;
private final TurnTokenGenerator tokenGenerator;
public CallRoutingController(
final RateLimiters rateLimiters,
final TurnCallRouter turnCallRouter,
final TurnTokenGenerator tokenGenerator
) {
this.rateLimiters = rateLimiters;
this.turnCallRouter = turnCallRouter;
this.tokenGenerator = tokenGenerator;
}
@GET
@Path("/relays")
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Get 1:1 calling relay options for the client",
description = """
Get 1:1 relay addresses in IpV4, Ipv6, and URL formats.
"""
)
@ApiResponse(responseCode = "200", description = "`JSON` with call endpoints.", useReturnTypeSchema = true)
@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.")
public TurnToken getCallingRelays(
final @Auth AuthenticatedAccount auth,
@Context ContainerRequestContext requestContext
) throws RateLimitExceededException {
UUID aci = auth.getAccount().getUuid();
rateLimiters.getCallEndpointLimiter().validate(aci);
Optional<InetAddress> address = Optional.empty();
try {
final String remoteAddress = (String) requestContext.getProperty(
RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);
address = Optional.of(InetAddress.getByName(remoteAddress));
} catch (UnknownHostException e) {
INVALID_IP_COUNTER.increment();
}
TurnServerOptions options = turnCallRouter.getRoutingFor(aci, address, TURN_INSTANCE_LIMIT);
return tokenGenerator.generateWithTurnServerOptions(options);
}
}

View File

@ -0,0 +1,115 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.geo;
import com.maxmind.db.CHMCache;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.GeoIp2Provider;
import io.dropwizard.lifecycle.Managed;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import org.apache.commons.compress.archivers.ArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
import org.whispersystems.textsecuregcm.configuration.MonitoredS3ObjectConfiguration;
import org.whispersystems.textsecuregcm.metrics.MetricsUtil;
import org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
public class MaxMindDatabaseManager implements Supplier<DatabaseReader>, Managed {
private final S3ObjectMonitor databaseMonitor;
private final AtomicReference<DatabaseReader> databaseReader = new AtomicReference<>();
private final String databaseTag;
private final Timer refreshTimer;
private static final Logger log = LoggerFactory.getLogger(MaxMindDatabaseManager.class);
public MaxMindDatabaseManager(
@Nonnull final ScheduledExecutorService executorService,
@Nonnull final MonitoredS3ObjectConfiguration configuration,
@Nonnull final String databaseTag
){
this.databaseMonitor = new S3ObjectMonitor(
configuration.s3Region(),
configuration.s3Bucket(),
configuration.objectKey(),
configuration.maxSize(),
executorService,
configuration.refreshInterval(),
this::handleDatabaseChanged
);
this.databaseTag = databaseTag;
this.refreshTimer = Metrics.timer(MetricsUtil.name(MaxMindDatabaseManager.class), "refresh", databaseTag);
}
private void handleDatabaseChanged(final InputStream inputStream) {
refreshTimer.record(() -> {
boolean foundDatabaseEntry = false;
try (final InputStream bufferedInputStream = new BufferedInputStream(inputStream);
final GzipCompressorInputStream gzipInputStream = new GzipCompressorInputStream(bufferedInputStream);
final TarArchiveInputStream tarInputStream = new TarArchiveInputStream(gzipInputStream)) {
ArchiveEntry nextEntry;
while ((nextEntry = tarInputStream.getNextEntry()) != null) {
if (nextEntry.getName().toLowerCase().endsWith(".mmdb")) {
foundDatabaseEntry = true;
final DatabaseReader oldReader = databaseReader.getAndSet(
new DatabaseReader.Builder(tarInputStream).withCache(new CHMCache()).build()
);
if (oldReader != null) {
oldReader.close();
}
break;
}
}
} catch (final IOException e) {
log.error(String.format("Failed to load MaxMind database, tag %s", databaseTag));
}
if (!foundDatabaseEntry) {
log.warn(String.format("No .mmdb entry loaded from input stream, tag %s", databaseTag));
}
});
}
@Override
public void start() throws Exception {
Managed.super.start();
databaseMonitor.start();
}
@Override
public void stop() throws Exception {
Managed.super.stop();
databaseMonitor.stop();
final DatabaseReader reader = databaseReader.getAndSet(null);
if(reader != null) {
reader.close();
}
}
@Override
public DatabaseReader get() {
return this.databaseReader.get();
}
}

View File

@ -49,6 +49,7 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
SET_BACKUP_ID("setBackupId", true, new RateLimiterConfig(2, Duration.ofDays(7))), SET_BACKUP_ID("setBackupId", true, new RateLimiterConfig(2, Duration.ofDays(7))),
PUSH_CHALLENGE_ATTEMPT("pushChallengeAttempt", true, new RateLimiterConfig(10, Duration.ofMinutes(144))), PUSH_CHALLENGE_ATTEMPT("pushChallengeAttempt", true, new RateLimiterConfig(10, Duration.ofMinutes(144))),
PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, Duration.ofHours(12))), PUSH_CHALLENGE_SUCCESS("pushChallengeSuccess", true, new RateLimiterConfig(2, Duration.ofHours(12))),
GET_CALLING_RELAYS("getCallingRelays", false, new RateLimiterConfig(100, Duration.ofMinutes(10))),
CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, Duration.ofMinutes(15))), CREATE_CALL_LINK("createCallLink", false, new RateLimiterConfig(100, Duration.ofMinutes(15))),
INBOUND_MESSAGE_BYTES("inboundMessageBytes", true, new RateLimiterConfig(128 * 1024 * 1024, Duration.ofNanos(500_000))), INBOUND_MESSAGE_BYTES("inboundMessageBytes", true, new RateLimiterConfig(128 * 1024 * 1024, Duration.ofNanos(500_000))),
EXTERNAL_SERVICE_CREDENTIALS("externalServiceCredentials", true, new RateLimiterConfig(100, Duration.ofMinutes(15))), EXTERNAL_SERVICE_CREDENTIALS("externalServiceCredentials", true, new RateLimiterConfig(100, Duration.ofMinutes(15))),
@ -220,6 +221,10 @@ public class RateLimiters extends BaseRateLimiters<RateLimiters.For> {
return forDescriptor(For.CREATE_CALL_LINK); return forDescriptor(For.CREATE_CALL_LINK);
} }
public RateLimiter getCallEndpointLimiter() {
return forDescriptor(For.GET_CALLING_RELAYS);
}
public RateLimiter getInboundMessageBytes() { public RateLimiter getInboundMessageBytes() {
return forDescriptor(For.INBOUND_MESSAGE_BYTES); return forDescriptor(For.INBOUND_MESSAGE_BYTES);
} }

View File

@ -10,13 +10,19 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Locale.LanguageRange; import java.util.Locale.LanguageRange;
import java.util.Optional; import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Function; import java.util.function.Function;
import java.util.random.RandomGenerator;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -26,6 +32,8 @@ import org.apache.commons.lang3.StringUtils;
public class Util { public class Util {
private static final RandomGenerator rng = new Random();
private static final Pattern COUNTRY_CODE_PATTERN = Pattern.compile("^\\+([17]|2[07]|3[0123469]|4[013456789]|5[12345678]|6[0123456]|8[1246]|9[0123458]|\\d{3})"); private static final Pattern COUNTRY_CODE_PATTERN = Pattern.compile("^\\+([17]|2[07]|3[0123469]|4[013456789]|5[12345678]|6[0123456]|8[1246]|9[0123458]|\\d{3})");
private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance(); private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance();
@ -160,4 +168,47 @@ public class Util {
return n == Long.MIN_VALUE ? 0 : Math.abs(n); return n == Long.MIN_VALUE ? 0 : Math.abs(n);
} }
/**
* Chooses min(values.size(), n) random values.
* <br>
* Copies the input Array - use for small lists only or for when n/values.size() is near 1.
*/
public static <E> List<E> randomNOf(List<E> values, int n) {
if(values == null || values.isEmpty()) {
return Collections.emptyList();
}
List<E> result = new ArrayList<>(values);
if(n >= values.size()) {
return result;
}
Collections.shuffle(result);
return result.stream().limit(n).toList();
}
/**
* Chooses min(values.size(), n) random values. Return value is in stable order from input values.
* Not uniform random, but good enough.
* <br>
* Does NOT copy the input Array.
*/
public static <E> List<E> randomNOfStable(List<E> values, int n) {
if(values == null || values.isEmpty()) {
return Collections.emptyList();
}
if(n >= values.size()) {
return values;
}
Set<Integer> indices = new HashSet<>(rng.ints(0, values.size()).distinct().limit(n).boxed().toList());
List<E> result = new ArrayList<E>(n);
for(int i = 0; i < values.size() && result.size() < n; i++) {
if(indices.contains(i)) {
result.add(values.get(i));
}
}
return result;
}
} }

View File

@ -0,0 +1,101 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.stream.Stream;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
public class CallDnsRecordsManagerTest {
@Test
public void testParseDnsRecords() throws IOException {
var input = """
{
"aByRegion": {
"datacenter-1": [
"127.0.0.1"
],
"datacenter-2": [
"127.0.0.2",
"127.0.0.3"
],
"datacenter-3": [
"127.0.0.4",
"127.0.0.5"
],
"datacenter-4": [
"127.0.0.6",
"127.0.0.7"
]
},
"aaaaByRegion": {
"datacenter-1": [
"2600:1111:2222:3333:0:20:0:0",
"2600:1111:2222:3333:0:21:0:0",
"2600:1111:2222:3333:0:22:0:0"
],
"datacenter-2": [
"2600:1111:2222:3333:0:23:0:0",
"2600:1111:2222:3333:0:24:0:0"
],
"datacenter-3": [
"2600:1111:2222:3333:0:25:0:0",
"2600:1111:2222:3333:0:26:0:0"
],
"datacenter-4": [
"2600:1111:2222:3333:0:27:0:0"
]
}
}
""";
var actual = CallDnsRecordsManager.parseRecords(new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)));
var expected = new CallDnsRecords(
Map.of(
"datacenter-1", Stream.of("127.0.0.1").map(this::getAddressByName).toList(),
"datacenter-2", Stream.of("127.0.0.2", "127.0.0.3").map(this::getAddressByName).toList(),
"datacenter-3", Stream.of("127.0.0.4", "127.0.0.5").map(this::getAddressByName).toList(),
"datacenter-4", Stream.of("127.0.0.6", "127.0.0.7").map(this::getAddressByName).toList()
),
Map.of(
"datacenter-1", Stream.of(
"2600:1111:2222:3333:0:20:0:0",
"2600:1111:2222:3333:0:21:0:0",
"2600:1111:2222:3333:0:22:0:0"
).map(this::getAddressByName).toList(),
"datacenter-2", Stream.of(
"2600:1111:2222:3333:0:23:0:0",
"2600:1111:2222:3333:0:24:0:0")
.map(this::getAddressByName).toList(),
"datacenter-3", Stream.of(
"2600:1111:2222:3333:0:25:0:0",
"2600:1111:2222:3333:0:26:0:0")
.map(this::getAddressByName).toList(),
"datacenter-4", Stream.of(
"2600:1111:2222:3333:0:27:0:0"
).map(this::getAddressByName).toList()
)
);
assertThat(actual).isEqualTo(expected);
}
InetAddress getAddressByName(String ip) {
try {
return InetAddress.getByName(ip) ;
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,245 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class CallRoutingTableParserTest {
@Test
public void testParserSuccess() throws IOException {
var input =
"""
192.1.12.0/24 datacenter-1 datacenter-2 datacenter-3
193.123.123.0/24 datacenter-1 datacenter-2
1.123.123.0/24 datacenter-1
2001:db8:b0aa::/48 datacenter-1
2001:db8:b0ab::/48 datacenter-3 datacenter-1 datacenter-2
2001:db8:b0ac::/48 datacenter-2 datacenter-1
SA-SR-v4 datacenter-3
SA-UY-v4 datacenter-3 datacenter-1 datacenter-2
NA-US-VA-v6 datacenter-2 datacenter-1
""";
var actual = CallRoutingTableParser.fromTsv(new StringReader(input));
var expected = new CallRoutingTable(
Map.of(
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("192.1.12.0/24"), List.of("datacenter-1", "datacenter-2", "datacenter-3"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("193.123.123.0/24"), List.of("datacenter-1", "datacenter-2"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("1.123.123.0/24"), List.of("datacenter-1")
),
Map.of(
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0aa::/48"), List.of("datacenter-1"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ab::/48"), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ac::/48"), List.of("datacenter-2", "datacenter-1")
),
Map.of(
new CallRoutingTable.GeoKey("SA", "SR", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3"),
new CallRoutingTable.GeoKey("SA", "UY", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
new CallRoutingTable.GeoKey("NA", "US", Optional.of("VA"), CallRoutingTable.Protocol.v6), List.of("datacenter-2", "datacenter-1")
)
);
assertThat(actual).isEqualTo(expected);
}
@Test
public void testParserVariousWhitespaceSuccess() throws IOException {
var input =
"""
192.1.12.0/24\t \tdatacenter-1\t\t datacenter-2 datacenter-3
\t193.123.123.0/24\tdatacenter-1\tdatacenter-2
1.123.123.0/24\t datacenter-1
2001:db8:b0aa::/48\t \tdatacenter-1
2001:db8:b0ab::/48 \tdatacenter-3\tdatacenter-1 datacenter-2
2001:db8:b0ac::/48\tdatacenter-2\tdatacenter-1
SA-SR-v4 datacenter-3
SA-UY-v4\tdatacenter-3\tdatacenter-1\tdatacenter-2
NA-US-VA-v6 datacenter-2 \tdatacenter-1
""";
var actual = CallRoutingTableParser.fromTsv(new StringReader(input));
var expected = new CallRoutingTable(
Map.of(
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("192.1.12.0/24"), List.of("datacenter-1", "datacenter-2", "datacenter-3"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("193.123.123.0/24"), List.of("datacenter-1", "datacenter-2"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("1.123.123.0/24"), List.of("datacenter-1")
),
Map.of(
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0aa::/48"), List.of("datacenter-1"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ab::/48"), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ac::/48"), List.of("datacenter-2", "datacenter-1")
),
Map.of(
new CallRoutingTable.GeoKey("SA", "SR", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3"),
new CallRoutingTable.GeoKey("SA", "UY", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
new CallRoutingTable.GeoKey("NA", "US", Optional.of("VA"), CallRoutingTable.Protocol.v6), List.of("datacenter-2", "datacenter-1")
)
);
assertThat(actual).isEqualTo(expected);
}
@Test
public void testParserMissingSection() throws IOException {
var input =
"""
192.1.12.0/24\t \tdatacenter-1\t\t datacenter-2 datacenter-3
193.123.123.0/24\tdatacenter-1\tdatacenter-2
1.123.123.0/24\t datacenter-1
SA-SR-v4 datacenter-3
SA-UY-v4\tdatacenter-3\tdatacenter-1\tdatacenter-2
NA-US-VA-v6 datacenter-2 \tdatacenter-1
""";
var actual = CallRoutingTableParser.fromTsv(new StringReader(input));
var expected = new CallRoutingTable(
Map.of(
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("192.1.12.0/24"), List.of("datacenter-1", "datacenter-2", "datacenter-3"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("193.123.123.0/24"), List.of("datacenter-1", "datacenter-2"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("1.123.123.0/24"), List.of("datacenter-1")
),
Map.of(),
Map.of(
new CallRoutingTable.GeoKey("SA", "SR", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3"),
new CallRoutingTable.GeoKey("SA", "UY", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
new CallRoutingTable.GeoKey("NA", "US", Optional.of("VA"), CallRoutingTable.Protocol.v6), List.of("datacenter-2", "datacenter-1")
)
);
assertThat(actual).isEqualTo(expected);
}
@Test
public void testParserMixedSections() throws IOException {
var input =
"""
1.123.123.0/24\t datacenter-1
2001:db8:b0aa::/48\t \tdatacenter-1
2001:db8:b0ab::/48 \tdatacenter-3\tdatacenter-1 datacenter-2
2001:db8:b0ac::/48\tdatacenter-2\tdatacenter-1
192.1.12.0/24\t \tdatacenter-1\t\t datacenter-2 datacenter-3
193.123.123.0/24\tdatacenter-1\tdatacenter-2
SA-SR-v4 datacenter-3
SA-UY-v4\tdatacenter-3\tdatacenter-1\tdatacenter-2
NA-US-VA-v6 datacenter-2 \tdatacenter-1
""";
var actual = CallRoutingTableParser.fromTsv(new StringReader(input));
var expected = new CallRoutingTable(
Map.of(
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("1.123.123.0/24"), List.of("datacenter-1"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("192.1.12.0/24"), List.of("datacenter-1", "datacenter-2", "datacenter-3"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("193.123.123.0/24"), List.of("datacenter-1", "datacenter-2")
),
Map.of(
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0aa::/48"), List.of("datacenter-1"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ab::/48"), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ac::/48"), List.of("datacenter-2", "datacenter-1")
),
Map.of(
new CallRoutingTable.GeoKey("SA", "SR", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3"),
new CallRoutingTable.GeoKey("SA", "UY", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
new CallRoutingTable.GeoKey("NA", "US", Optional.of("VA"), CallRoutingTable.Protocol.v6), List.of("datacenter-2", "datacenter-1")
)
);
assertThat(actual).isEqualTo(expected);
}
@Test
public void testJsonParserSuccess() throws IOException {
var input =
"""
{
"ipv4GeoToDataCenters": {
"SA-SR": ["datacenter-3"],
"SA-UY": ["datacenter-3", "datacenter-1", "datacenter-2"]
},
"ipv6GeoToDataCenters": {
"NA-US-VA": ["datacenter-2", "datacenter-1"]
},
"ipv4SubnetsToDatacenters": {
"192.1.12.0": ["datacenter-1", "datacenter-2", "datacenter-3"],
"193.123.123.0": ["datacenter-1", "datacenter-2"],
"1.123.123.0": ["datacenter-1"]
},
"ipv6SubnetsToDatacenters": {
"2001:db8:b0aa::": ["datacenter-1"],
"2001:db8:b0ab::": ["datacenter-3", "datacenter-1", "datacenter-2"],
"2001:db8:b0ac::": ["datacenter-2", "datacenter-1"]
}
}
""";
var actual = CallRoutingTableParser.fromJson(new StringReader(input));
var expected = new CallRoutingTable(
Map.of(
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("192.1.12.0/24"), List.of("datacenter-1", "datacenter-2", "datacenter-3"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("193.123.123.0/24"), List.of("datacenter-1", "datacenter-2"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("1.123.123.0/24"), List.of("datacenter-1")
),
Map.of(
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0aa::/48"), List.of("datacenter-1"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ab::/48"), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ac::/48"), List.of("datacenter-2", "datacenter-1")
),
Map.of(
new CallRoutingTable.GeoKey("SA", "SR", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3"),
new CallRoutingTable.GeoKey("SA", "UY", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
new CallRoutingTable.GeoKey("NA", "US", Optional.of("VA"), CallRoutingTable.Protocol.v6), List.of("datacenter-2", "datacenter-1")
)
);
assertThat(actual).isEqualTo(expected);
}
@Test
public void testParseVariousEdgeCases() throws IOException {
var input =
"""
{
"ipv4GeoToDataCenters": {},
"ipv6GeoToDataCenters": {},
"ipv4SubnetsToDatacenters": {},
"ipv6SubnetsToDatacenters": {}
}
""";
assertThat(CallRoutingTableParser.fromJson(new StringReader(input))).isEqualTo(CallRoutingTable.empty());
assertThat(CallRoutingTableParser.fromJson(new StringReader("{}"))).isEqualTo(CallRoutingTable.empty());
}
}

View File

@ -0,0 +1,140 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import io.vavr.Tuple2;
import org.junit.jupiter.api.Test;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.UnknownHostException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
public class CallRoutingTableTest {
static final CallRoutingTable basicTable = new CallRoutingTable(
Map.of(
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("192.1.12.0/24"), List.of("datacenter-1", "datacenter-2", "datacenter-3"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("193.123.123.0/24"), List.of("datacenter-1", "datacenter-2"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("1.123.123.0/24"), List.of("datacenter-4")
),
Map.of(
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0aa::/48"), List.of("datacenter-1"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ab::/48"), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ac::/48"), List.of("datacenter-2", "datacenter-1")
),
Map.of(
new CallRoutingTable.GeoKey("SA", "SR", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3"),
new CallRoutingTable.GeoKey("SA", "UY", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
new CallRoutingTable.GeoKey("NA", "US", Optional.of("VA"), CallRoutingTable.Protocol.v6), List.of("datacenter-2", "datacenter-1"),
new CallRoutingTable.GeoKey("NA", "US", Optional.empty(), CallRoutingTable.Protocol.v6), List.of("datacenter-3", "datacenter-4")
)
);
// has overlapping subnets
static final CallRoutingTable overlappingTable = new CallRoutingTable(
Map.of(
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("192.1.12.0/24"), List.of("datacenter-1", "datacenter-2", "datacenter-3"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("1.123.123.0/24"), List.of("datacenter-4"),
(CidrBlock.IpV4CidrBlock) CidrBlock.parseCidrBlock("1.123.0.0/16"), List.of("datacenter-1")
),
Map.of(
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0aa::/48"), List.of("datacenter-1"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0ac::/48"), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
(CidrBlock.IpV6CidrBlock) CidrBlock.parseCidrBlock("2001:db8:b0a0::/44"), List.of("datacenter-2", "datacenter-1")
),
Map.of(
new CallRoutingTable.GeoKey("SA", "SR", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3"),
new CallRoutingTable.GeoKey("SA", "UY", Optional.empty(), CallRoutingTable.Protocol.v4), List.of("datacenter-3", "datacenter-1", "datacenter-2"),
new CallRoutingTable.GeoKey("NA", "US", Optional.of("VA"), CallRoutingTable.Protocol.v6), List.of("datacenter-2", "datacenter-1")
)
);
@Test
void testGetFastestDataCentersBySubnet() throws UnknownHostException {
var v4address = Inet4Address.getByName("1.123.123.1");
var actualV4 = basicTable.getDatacentersBySubnet(v4address);
assertThat(actualV4).isEqualTo(List.of("datacenter-4"));
var v6address = Inet6Address.getByName("2001:db8:b0ac:aaaa:aaaa:aaaa:aaaa:0001");
var actualV6 = basicTable.getDatacentersBySubnet(v6address);
assertThat(actualV6).isEqualTo(List.of("datacenter-2", "datacenter-1"));
}
@Test
void testGetFastestDataCentersBySubnetOverlappingTable() throws UnknownHostException {
var v4address = Inet4Address.getByName("1.123.123.1");
var actualV4 = overlappingTable.getDatacentersBySubnet(v4address);
assertThat(actualV4).isEqualTo(List.of("datacenter-4"));
var v6address = Inet6Address.getByName("2001:db8:b0ac:aaaa:aaaa:aaaa:aaaa:0001");
var actualV6 = overlappingTable.getDatacentersBySubnet(v6address);
assertThat(actualV6).isEqualTo(List.of("datacenter-3", "datacenter-1", "datacenter-2"));
}
@Test
void testGetFastestDataCentersByGeo() {
var actual = basicTable.getDatacentersByGeo("SA", "SR", Optional.empty());
assertThat(actual).isEqualTo(List.of("datacenter-3"));
var actualWithSubdvision = basicTable.getDatacentersByGeo("NA", "US", Optional.of("VA"));
assertThat(actualWithSubdvision).isEqualTo(List.of("datacenter-2", "datacenter-1"));
}
@Test
void testGetFastestDataCentersByGeoFallback() {
var actualExactMatch = basicTable.getDatacentersByGeo("NA", "US", Optional.of("VA"));
assertThat(actualExactMatch).isEqualTo(List.of("datacenter-2", "datacenter-1"));
var actualApproximateMatch = basicTable.getDatacentersByGeo("NA", "US", Optional.of("MD"));
assertThat(actualApproximateMatch).isEqualTo(List.of("datacenter-3", "datacenter-4"));
}
@Test
void testGetFastestDatacentersPrioritizesSubnet() throws UnknownHostException {
var v4address = Inet4Address.getByName("1.123.123.1");
var actual = basicTable.getDatacentersFor(v4address, "NA", "US", Optional.of("VA"));
assertThat(actual).isEqualTo(List.of("datacenter-4", "datacenter-2", "datacenter-1"));
}
@Test
void testGetFastestDatacentersEmptySubnet() throws UnknownHostException {
var v4address = Inet4Address.getByName("200.200.123.1");
var actual = basicTable.getDatacentersFor(v4address, "NA", "US", Optional.of("VA"));
assertThat(actual).isEqualTo(List.of("datacenter-2", "datacenter-1"));
}
@Test
void testGetFastestDatacentersEmptySubnetTakesExtraFromGeo() throws UnknownHostException {
var v4address = Inet4Address.getByName("200.200.123.1");
var actual = basicTable.getDatacentersFor(v4address, "SA", "UY", Optional.empty());
assertThat(actual).isEqualTo(List.of("datacenter-3", "datacenter-1", "datacenter-2"));
}
@Test
void testGetFastestDatacentersEmptyGeoResults() throws UnknownHostException {
var v4address = Inet4Address.getByName("1.123.123.1");
var actual = basicTable.getDatacentersFor(v4address, "ZZ", "AA", Optional.empty());
assertThat(actual).isEqualTo(List.of("datacenter-4"));
}
@Test
void testGetFastestDatacentersEmptyGeoTakesFromSubnet() throws UnknownHostException {
var v4address = Inet4Address.getByName("192.1.12.1");
var actual = basicTable.getDatacentersFor(v4address, "ZZ", "AA", Optional.empty());
assertThat(actual).isEqualTo(List.of("datacenter-1", "datacenter-2", "datacenter-3"));
}
@Test
void testGetFastestDatacentersDistinct() throws UnknownHostException {
var v6address = Inet6Address.getByName("2001:db8:b0ac:aaaa:aaaa:aaaa:aaaa:0001");
var actual = basicTable.getDatacentersFor(v6address, "NA", "US", Optional.of("VA"));
assertThat(actual).isEqualTo(List.of("datacenter-2", "datacenter-1"));
}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.HexFormat;
import static org.assertj.core.api.Assertions.assertThat;
public class CidrBlockTest {
private HexFormat hex = HexFormat.ofDelimiter(":").withLowerCase();
@Test
public void testIPv4CidrBlockParseSuccess() {
var actual = CidrBlock.parseCidrBlock("255.32.15.0/24");
var expected = new CidrBlock.IpV4CidrBlock(0xFF_20_0F_00, 0xFFFFFF00, 24);
assertThat(actual).isInstanceOf(CidrBlock.IpV4CidrBlock.class);
assertThat(actual).isEqualTo(expected);
}
@Test
public void testIPv6CidrBlockParseSuccess() {
var actual = CidrBlock.parseCidrBlock("2001:db8:b0aa::/48");
var expected = new CidrBlock.IpV6CidrBlock(
new BigInteger(hex.parseHex("20:01:0d:b8:b0:aa:00:00:00:00:00:00:00:00:00:00")),
new BigInteger(hex.parseHex("FF:FF:FF:FF:FF:FF:00:00:00:00:00:00:00:00:00:00")),
48
);
assertThat(actual).isInstanceOf(CidrBlock.IpV6CidrBlock.class);
assertThat(actual).isEqualTo(expected);
}
@Test
public void testIPv4InBlock() throws UnknownHostException {
var block = CidrBlock.parseCidrBlock("255.32.15.0/24");
assertThat(block.ipInBlock(InetAddress.getByName("255.32.15.123"))).isTrue();
assertThat(block.ipInBlock(InetAddress.getByName("255.32.15.0"))).isTrue();
assertThat(block.ipInBlock(InetAddress.getByName("255.32.16.0"))).isFalse();
assertThat(block.ipInBlock(InetAddress.getByName("255.33.15.0"))).isFalse();
assertThat(block.ipInBlock(InetAddress.getByName("254.33.15.0"))).isFalse();
}
@Test
public void testIPv6InBlock() throws UnknownHostException {
var block = CidrBlock.parseCidrBlock("2001:db8:b0aa::/48");
assertThat(block.ipInBlock(InetAddress.getByName("2001:db8:b0aa:1:1::"))).isTrue();
assertThat(block.ipInBlock(InetAddress.getByName("2001:db8:b0aa:0:0::"))).isTrue();
assertThat(block.ipInBlock(InetAddress.getByName("2001:db8:b0ab:1:1::"))).isFalse();
assertThat(block.ipInBlock(InetAddress.getByName("2001:da8:b0aa:1:1::"))).isFalse();
}
}

View File

@ -0,0 +1,137 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
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.util.Collection;
import java.util.List;
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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class DynamicConfigTurnRouterTest {
@Test
public void testAlwaysSelectFirst() throws JsonProcessingException {
final String configString = """
captcha:
scoreFloor: 1.0
turn:
uriConfigs:
- uris:
- always1.org
- always2.org
- uris:
- never.org
weight: 0
""";
DynamicConfiguration config = DynamicConfigurationManager
.parseConfiguration(configString, DynamicConfiguration.class)
.orElseThrow();
@SuppressWarnings("unchecked")
DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager = mock(
DynamicConfigurationManager.class);
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final DynamicConfigTurnRouter configTurnRouter = new DynamicConfigTurnRouter(mockDynamicConfigManager);
final long COUNT = 1000;
final Map<String, Long> urlCounts = Stream
.generate(configTurnRouter::randomUrls)
.limit(COUNT)
.flatMap(Collection::stream)
.collect(Collectors.groupingBy(i -> i, Collectors.counting()));
assertThat(urlCounts.get("always1.org")).isEqualTo(COUNT);
assertThat(urlCounts.get("always2.org")).isEqualTo(COUNT);
assertThat(urlCounts).doesNotContainKey("never.org");
}
@Test
public void testProbabilisticUrls() throws JsonProcessingException {
final String configString = """
captcha:
scoreFloor: 1.0
turn:
uriConfigs:
- uris:
- always.org
- sometimes1.org
weight: 5
- uris:
- always.org
- sometimes2.org
weight: 5
""";
DynamicConfiguration config = DynamicConfigurationManager
.parseConfiguration(configString, DynamicConfiguration.class)
.orElseThrow();
@SuppressWarnings("unchecked")
DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager = mock(
DynamicConfigurationManager.class);
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final DynamicConfigTurnRouter configTurnRouter = new DynamicConfigTurnRouter(mockDynamicConfigManager);
final long COUNT = 1000;
final Map<String, Long> urlCounts = Stream
.generate(configTurnRouter::randomUrls)
.limit(COUNT)
.flatMap(Collection::stream)
.collect(Collectors.groupingBy(i -> i, Collectors.counting()));
assertThat(urlCounts.get("always.org")).isEqualTo(COUNT);
assertThat(urlCounts.get("sometimes1.org")).isGreaterThan(0);
assertThat(urlCounts.get("sometimes2.org")).isGreaterThan(0);
}
@Test
public void testExplicitEnrollment() throws JsonProcessingException {
final String configString = """
captcha:
scoreFloor: 1.0
turn:
secret: bloop
uriConfigs:
- uris:
- enrolled.org
weight: 0
enrolledAcis:
- 732506d7-d04f-43a4-b1d7-8a3a91ebe8a6
- uris:
- unenrolled.org
weight: 1
""";
DynamicConfiguration config = DynamicConfigurationManager
.parseConfiguration(configString, DynamicConfiguration.class)
.orElseThrow();
@SuppressWarnings("unchecked")
DynamicConfigurationManager<DynamicConfiguration> mockDynamicConfigManager = mock(
DynamicConfigurationManager.class);
when(mockDynamicConfigManager.getConfiguration()).thenReturn(config);
final DynamicConfigTurnRouter configTurnRouter = new DynamicConfigTurnRouter(mockDynamicConfigManager);
List<String> urls = configTurnRouter.targetedUrls(UUID.fromString("732506d7-d04f-43a4-b1d7-8a3a91ebe8a6"));
assertThat(urls.getFirst()).isEqualTo("enrolled.org");
urls = configTurnRouter.targetedUrls(UUID.randomUUID());
assertTrue(urls.isEmpty());
}
}

View File

@ -0,0 +1,262 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.calls.routing;
import com.maxmind.geoip2.DatabaseReader;
import com.maxmind.geoip2.exception.GeoIp2Exception;
import com.maxmind.geoip2.model.CityResponse;
import com.maxmind.geoip2.record.Continent;
import com.maxmind.geoip2.record.Country;
import com.maxmind.geoip2.record.Subdivision;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class TurnCallRouterTest {
private final static String TEST_HOSTNAME = "subdomain.example.org";
private final static List<String> TEST_URLS_WITH_HOSTS = List.of(
"one.example.com",
"two.example.com",
"three.example.com"
);
private CallRoutingTable performanceTable;
private CallRoutingTable manualTable;
private DynamicConfigTurnRouter configTurnRouter;
private DatabaseReader geoIp;
private Country country;
private Continent continent;
private CallDnsRecords callDnsRecords;
private Subdivision subdivision;
private UUID aci = UUID.randomUUID();
@BeforeEach
void setup() throws IOException, GeoIp2Exception {
performanceTable = mock(CallRoutingTable.class);
manualTable = mock(CallRoutingTable.class);
configTurnRouter = mock(DynamicConfigTurnRouter.class);
geoIp = mock(DatabaseReader.class);
continent = mock(Continent.class);
country = mock(Country.class);
subdivision = mock(Subdivision.class);
ArrayList<Subdivision> subdivisions = new ArrayList<>();
subdivisions.add(subdivision);
when(geoIp.city(any())).thenReturn(new CityResponse(null, continent, country, null, null, null, null, null, subdivisions, null));
setupDefault();
}
void setupDefault() {
when(configTurnRouter.targetedUrls(any())).thenReturn(Collections.emptyList());
when(configTurnRouter.randomUrls()).thenReturn(TEST_URLS_WITH_HOSTS);
when(configTurnRouter.getHostname()).thenReturn(TEST_HOSTNAME);
when(configTurnRouter.shouldRandomize()).thenReturn(false);
when(manualTable.getDatacentersFor(any(), any(), any(), any())).thenReturn(Collections.emptyList());
when(continent.getCode()).thenReturn("NA");
when(country.getIsoCode()).thenReturn("US");
when(subdivision.getIsoCode()).thenReturn("VA");
try {
callDnsRecords = new CallDnsRecords(
Map.of(
"dc-manual", List.of(InetAddress.getByName("1.1.1.1")),
"dc-performance1", List.of(
InetAddress.getByName("9.9.9.1"),
InetAddress.getByName("9.9.9.2")
),
"dc-performance2", List.of(InetAddress.getByName("9.9.9.3")),
"dc-performance3", List.of(InetAddress.getByName("9.9.9.4"))
),
Map.of(
"dc-manual", List.of(InetAddress.getByName("2222:1111:0:dead::")),
"dc-performance1", List.of(
InetAddress.getByName("2222:1111:0:abc0::"),
InetAddress.getByName("2222:1111:0:abc1::")
),
"dc-performance2", List.of(InetAddress.getByName("2222:1111:0:abc2::")),
"dc-performance3", List.of(InetAddress.getByName("2222:1111:0:abc3::"))
)
);
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
}
private TurnCallRouter router() {
return new TurnCallRouter(
() -> callDnsRecords,
() -> performanceTable,
() -> manualTable,
configTurnRouter,
() -> geoIp
);
}
TurnServerOptions optionsWithUrls(List<String> urls) {
return new TurnServerOptions(
TEST_HOSTNAME,
urls,
TEST_URLS_WITH_HOSTS
);
}
@Test
public void testPrioritizesTargetedUrls() throws UnknownHostException {
List<String> targetedUrls = List.of(
"targeted1.example.com",
"targeted.example.com"
);
when(configTurnRouter.targetedUrls(any()))
.thenReturn(targetedUrls);
assertThat(router().getRoutingFor(aci, Optional.of(InetAddress.getByName("0.0.0.1")), 10))
.isEqualTo(new TurnServerOptions(
TEST_HOSTNAME,
null,
targetedUrls
));
}
@Test
public void testRandomizes() throws UnknownHostException {
when(configTurnRouter.shouldRandomize())
.thenReturn(true);
assertThat(router().getRoutingFor(aci, Optional.of(InetAddress.getByName("0.0.0.1")), 10))
.isEqualTo(optionsWithUrls(null));
}
@Test
public void testOrderedByPerformance() throws UnknownHostException {
when(performanceTable.getDatacentersFor(any(), any(), any(), any()))
.thenReturn(List.of("dc-performance2", "dc-performance1"));
assertThat(router().getRoutingFor(aci, Optional.of(InetAddress.getByName("0.0.0.1")), 10))
.isEqualTo(optionsWithUrls(List.of(
"stun:9.9.9.3",
"turn:9.9.9.3",
"turn:9.9.9.3:80?transport=tcp",
"turns:9.9.9.3:443?transport=tcp",
"stun:9.9.9.1",
"turn:9.9.9.1",
"turn:9.9.9.1:80?transport=tcp",
"turns:9.9.9.1:443?transport=tcp",
"stun:9.9.9.2",
"turn:9.9.9.2",
"turn:9.9.9.2:80?transport=tcp",
"turns:9.9.9.2:443?transport=tcp",
"stun:2222:1111:0:abc2:0:0:0:0",
"turn:2222:1111:0:abc2:0:0:0:0",
"turn:2222:1111:0:abc2:0:0:0:0:80?transport=tcp",
"turns:2222:1111:0:abc2:0:0:0:0:443?transport=tcp",
"stun:2222:1111:0:abc0:0:0:0:0",
"turn:2222:1111:0:abc0:0:0:0:0",
"turn:2222:1111:0:abc0:0:0:0:0:80?transport=tcp",
"turns:2222:1111:0:abc0:0:0:0:0:443?transport=tcp",
"stun:2222:1111:0:abc1:0:0:0:0",
"turn:2222:1111:0:abc1:0:0:0:0",
"turn:2222:1111:0:abc1:0:0:0:0:80?transport=tcp",
"turns:2222:1111:0:abc1:0:0:0:0:443?transport=tcp"
)));
}
@Test
public void testPrioritizesManualRecords() throws UnknownHostException {
when(performanceTable.getDatacentersFor(any(), any(), any(), any()))
.thenReturn(List.of("dc-performance1"));
when(manualTable.getDatacentersFor(any(), any(), any(), any()))
.thenReturn(List.of("dc-manual"));
assertThat(router().getRoutingFor(aci, Optional.of(InetAddress.getByName("0.0.0.1")), 10))
.isEqualTo(optionsWithUrls(List.of(
"stun:1.1.1.1",
"turn:1.1.1.1",
"turn:1.1.1.1:80?transport=tcp",
"turns:1.1.1.1:443?transport=tcp",
"stun:2222:1111:0:dead:0:0:0:0",
"turn:2222:1111:0:dead:0:0:0:0",
"turn:2222:1111:0:dead:0:0:0:0:80?transport=tcp",
"turns:2222:1111:0:dead:0:0:0:0:443?transport=tcp"
)));
}
@Test
public void testLimitReturnsHalfIpv4AndPrioritizesPerformance() throws UnknownHostException {
when(performanceTable.getDatacentersFor(any(), any(), any(), any()))
.thenReturn(List.of("dc-performance3", "dc-performance2", "dc-performance1"));
assertThat(router().getRoutingFor(aci, Optional.of(InetAddress.getByName("0.0.0.1")), 6))
.isEqualTo(optionsWithUrls(List.of(
"stun:9.9.9.4",
"turn:9.9.9.4",
"turn:9.9.9.4:80?transport=tcp",
"turns:9.9.9.4:443?transport=tcp",
"stun:9.9.9.3",
"turn:9.9.9.3",
"turn:9.9.9.3:80?transport=tcp",
"turns:9.9.9.3:443?transport=tcp",
"stun:9.9.9.1",
"turn:9.9.9.1",
"turn:9.9.9.1:80?transport=tcp",
"turns:9.9.9.1:443?transport=tcp",
"stun:2222:1111:0:abc3:0:0:0:0",
"turn:2222:1111:0:abc3:0:0:0:0",
"turn:2222:1111:0:abc3:0:0:0:0:80?transport=tcp",
"turns:2222:1111:0:abc3:0:0:0:0:443?transport=tcp",
"stun:2222:1111:0:abc2:0:0:0:0",
"turn:2222:1111:0:abc2:0:0:0:0",
"turn:2222:1111:0:abc2:0:0:0:0:80?transport=tcp",
"turns:2222:1111:0:abc2:0:0:0:0:443?transport=tcp",
"stun:2222:1111:0:abc0:0:0:0:0",
"turn:2222:1111:0:abc0:0:0:0:0",
"turn:2222:1111:0:abc0:0:0:0:0:80?transport=tcp",
"turns:2222:1111:0:abc0:0:0:0:0:443?transport=tcp"
)));
}
@Test
public void testNoDatacentersMatched() throws UnknownHostException {
when(performanceTable.getDatacentersFor(any(), any(), any(), any()))
.thenReturn(List.of());
assertThat(router().getRoutingFor(aci, Optional.of(InetAddress.getByName("0.0.0.1")), 10))
.isEqualTo(optionsWithUrls(List.of()));
}
@Test
public void testHandlesDatacenterNotInDnsRecords() throws UnknownHostException {
when(performanceTable.getDatacentersFor(any(), any(), any(), any()))
.thenReturn(List.of("unsynced-datacenter"));
assertThat(router().getRoutingFor(aci, Optional.of(InetAddress.getByName("0.0.0.1")), 10))
.isEqualTo(optionsWithUrls(List.of()));
}
}

View File

@ -333,6 +333,8 @@ class DynamicConfigurationTest {
weight: 2 weight: 2
enrolledAcis: enrolledAcis:
- 732506d7-d04f-43a4-b1d7-8a3a91ebe8a6 - 732506d7-d04f-43a4-b1d7-8a3a91ebe8a6
randomizeRate: 100_000
hostname: test.domain.org
"""); """);
DynamicTurnConfiguration turnConfiguration = DynamicConfigurationManager DynamicTurnConfiguration turnConfiguration = DynamicConfigurationManager
.parseConfiguration(config, DynamicConfiguration.class) .parseConfiguration(config, DynamicConfiguration.class)
@ -345,6 +347,8 @@ class DynamicConfigurationTest {
assertThat(turnConfiguration.getUriConfigs().get(1).getEnrolledAcis()) assertThat(turnConfiguration.getUriConfigs().get(1).getEnrolledAcis())
.containsExactly(UUID.fromString("732506d7-d04f-43a4-b1d7-8a3a91ebe8a6")); .containsExactly(UUID.fromString("732506d7-d04f-43a4-b1d7-8a3a91ebe8a6"));
assertThat(turnConfiguration.getHostname()).isEqualTo("test.domain.org");
assertThat(turnConfiguration.getRandomizeRate()).isEqualTo(100_000L);
} }
} }

View File

@ -0,0 +1,142 @@
/*
* Copyright 2013 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.when;
import com.google.common.net.HttpHeaders;
import io.dropwizard.auth.AuthValueFactoryProvider;
import io.dropwizard.testing.junit5.DropwizardExtensionsSupport;
import io.dropwizard.testing.junit5.ResourceExtension;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
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.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount;
import org.whispersystems.textsecuregcm.auth.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.dynamic.DynamicConfiguration;
import org.whispersystems.textsecuregcm.limits.RateLimiter;
import org.whispersystems.textsecuregcm.limits.RateLimiters;
import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;
import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;
import org.whispersystems.textsecuregcm.tests.util.AuthHelper;
import org.whispersystems.textsecuregcm.util.SystemMapper;
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();
@BeforeEach
void setup() {
when(rateLimiters.getCallEndpointLimiter()).thenReturn(getCallEndpointLimiter);
}
@Test
void testGetTurnEndpointsSuccess() throws UnknownHostException {
TurnServerOptions options = new TurnServerOptions(
"example.domain.org",
List.of("stun:12.34.56.78"),
List.of("stun:example.domain.org")
);
when(turnCallRouter.getRoutingFor(
eq(AuthHelper.VALID_UUID),
eq(Optional.of(InetAddress.getByName(REMOTE_ADDRESS))),
anyInt())
).thenReturn(options);
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()).isEqualTo(options.hostname());
assertThat(token.urlsWithIps()).isEqualTo(options.urlsWithIps());
assertThat(token.urls()).isEqualTo(options.urlsWithHostname());
}
}
@Test
void testGetTurnEndpointsInvalidIpSuccess() throws UnknownHostException {
TurnServerOptions options = new TurnServerOptions(
"example.domain.org",
List.of(),
List.of("stun:example.domain.org")
);
when(turnCallRouter.getRoutingFor(
eq(AuthHelper.VALID_UUID),
eq(Optional.of(InetAddress.getByName(REMOTE_ADDRESS))),
anyInt())
).thenReturn(options);
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()).isEqualTo(options.hostname());
assertThat(token.urlsWithIps()).isEqualTo(options.urlsWithIps());
assertThat(token.urls()).isEqualTo(options.urlsWithHostname());
}
}
@Test
void testGetTurnEndpointRateLimited() throws RateLimitExceededException {
doThrow(new RateLimitExceededException(null, false))
.when(getCallEndpointLimiter).validate(AuthHelper.VALID_UUID);
try(final 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(429);
}
}
}