Include a TURN credential TTL for clients in `GetCallingRelaysResponse`
This commit is contained in:
parent
9287aaf7ce
commit
28a0b9e84e
|
@ -482,7 +482,8 @@ turn:
|
||||||
- turn:%s
|
- turn:%s
|
||||||
- turn:%s:80?transport=tcp
|
- turn:%s:80?transport=tcp
|
||||||
- turns:%s:443?transport=tcp
|
- turns:%s:443?transport=tcp
|
||||||
ttl: 86400
|
requestedCredentialTtl: PT24H
|
||||||
|
clientCredentialTtl: PT12H
|
||||||
hostname: turn.cloudflare.example.com
|
hostname: turn.cloudflare.example.com
|
||||||
numHttpClients: 1
|
numHttpClients: 1
|
||||||
|
|
||||||
|
|
|
@ -673,7 +673,8 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
|
||||||
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
|
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
|
||||||
config.getTurnConfiguration().cloudflare().apiToken().value(),
|
config.getTurnConfiguration().cloudflare().apiToken().value(),
|
||||||
config.getTurnConfiguration().cloudflare().endpoint(),
|
config.getTurnConfiguration().cloudflare().endpoint(),
|
||||||
config.getTurnConfiguration().cloudflare().ttl(),
|
config.getTurnConfiguration().cloudflare().requestedCredentialTtl(),
|
||||||
|
config.getTurnConfiguration().cloudflare().clientCredentialTtl(),
|
||||||
config.getTurnConfiguration().cloudflare().urls(),
|
config.getTurnConfiguration().cloudflare().urls(),
|
||||||
config.getTurnConfiguration().cloudflare().urlsWithIps(),
|
config.getTurnConfiguration().cloudflare().urlsWithIps(),
|
||||||
config.getTurnConfiguration().cloudflare().hostname(),
|
config.getTurnConfiguration().cloudflare().hostname(),
|
||||||
|
|
|
@ -15,6 +15,7 @@ import java.net.Inet6Address;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
|
@ -39,16 +40,18 @@ public class CloudflareTurnCredentialsManager {
|
||||||
private final List<String> cloudflareTurnUrls;
|
private final List<String> cloudflareTurnUrls;
|
||||||
private final List<String> cloudflareTurnUrlsWithIps;
|
private final List<String> cloudflareTurnUrlsWithIps;
|
||||||
private final String cloudflareTurnHostname;
|
private final String cloudflareTurnHostname;
|
||||||
private final HttpRequest request;
|
private final HttpRequest getCredentialsRequest;
|
||||||
|
|
||||||
private final FaultTolerantHttpClient cloudflareTurnClient;
|
private final FaultTolerantHttpClient cloudflareTurnClient;
|
||||||
private final DnsNameResolver dnsNameResolver;
|
private final DnsNameResolver dnsNameResolver;
|
||||||
|
|
||||||
record CredentialRequest(long ttl) {}
|
private final Duration clientCredentialTtl;
|
||||||
|
|
||||||
record CloudflareTurnResponse(IceServer iceServers) {
|
private record CredentialRequest(long ttl) {}
|
||||||
|
|
||||||
record IceServer(
|
private record CloudflareTurnResponse(IceServer iceServers) {
|
||||||
|
|
||||||
|
private record IceServer(
|
||||||
String username,
|
String username,
|
||||||
String credential,
|
String credential,
|
||||||
List<String> urls) {
|
List<String> urls) {
|
||||||
|
@ -56,10 +59,17 @@ public class CloudflareTurnCredentialsManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken,
|
public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken,
|
||||||
final String cloudflareTurnEndpoint, final long cloudflareTurnTtl, final List<String> cloudflareTurnUrls,
|
final String cloudflareTurnEndpoint,
|
||||||
final List<String> cloudflareTurnUrlsWithIps, final String cloudflareTurnHostname,
|
final Duration requestedCredentialTtl,
|
||||||
final int cloudflareTurnNumHttpClients, final CircuitBreakerConfiguration circuitBreaker,
|
final Duration clientCredentialTtl,
|
||||||
final ExecutorService executor, final RetryConfiguration retry, final ScheduledExecutorService retryExecutor,
|
final List<String> cloudflareTurnUrls,
|
||||||
|
final List<String> cloudflareTurnUrlsWithIps,
|
||||||
|
final String cloudflareTurnHostname,
|
||||||
|
final int cloudflareTurnNumHttpClients,
|
||||||
|
final CircuitBreakerConfiguration circuitBreaker,
|
||||||
|
final ExecutorService executor,
|
||||||
|
final RetryConfiguration retry,
|
||||||
|
final ScheduledExecutorService retryExecutor,
|
||||||
final DnsNameResolver dnsNameResolver) {
|
final DnsNameResolver dnsNameResolver) {
|
||||||
|
|
||||||
this.cloudflareTurnClient = FaultTolerantHttpClient.newBuilder()
|
this.cloudflareTurnClient = FaultTolerantHttpClient.newBuilder()
|
||||||
|
@ -75,17 +85,24 @@ public class CloudflareTurnCredentialsManager {
|
||||||
this.cloudflareTurnHostname = cloudflareTurnHostname;
|
this.cloudflareTurnHostname = cloudflareTurnHostname;
|
||||||
this.dnsNameResolver = dnsNameResolver;
|
this.dnsNameResolver = dnsNameResolver;
|
||||||
|
|
||||||
|
final String credentialsRequestBody;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final String body = SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(cloudflareTurnTtl));
|
credentialsRequestBody =
|
||||||
this.request = HttpRequest.newBuilder()
|
SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(requestedCredentialTtl.toSeconds()));
|
||||||
.uri(URI.create(cloudflareTurnEndpoint))
|
} catch (final JsonProcessingException e) {
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.header("Authorization", String.format("Bearer %s", cloudflareTurnApiToken))
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
|
||||||
.build();
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalArgumentException(e);
|
throw new IllegalArgumentException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We repeat the same request to Cloudflare every time, so we can construct it once and re-use it
|
||||||
|
this.getCredentialsRequest = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(cloudflareTurnEndpoint))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", String.format("Bearer %s", cloudflareTurnApiToken))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(credentialsRequestBody))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.clientCredentialTtl = clientCredentialTtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public TurnToken retrieveFromCloudflare() throws IOException {
|
public TurnToken retrieveFromCloudflare() throws IOException {
|
||||||
|
@ -105,7 +122,7 @@ public class CloudflareTurnCredentialsManager {
|
||||||
final Timer.Sample sample = Timer.start();
|
final Timer.Sample sample = Timer.start();
|
||||||
final HttpResponse<String> response;
|
final HttpResponse<String> response;
|
||||||
try {
|
try {
|
||||||
response = cloudflareTurnClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();
|
response = cloudflareTurnClient.sendAsync(getCredentialsRequest, HttpResponse.BodyHandlers.ofString()).join();
|
||||||
sample.stop(Timer.builder(CREDENTIAL_FETCH_TIMER_NAME)
|
sample.stop(Timer.builder(CREDENTIAL_FETCH_TIMER_NAME)
|
||||||
.publishPercentileHistogram(true)
|
.publishPercentileHistogram(true)
|
||||||
.tags("outcome", "success")
|
.tags("outcome", "success")
|
||||||
|
@ -130,6 +147,7 @@ public class CloudflareTurnCredentialsManager {
|
||||||
return new TurnToken(
|
return new TurnToken(
|
||||||
cloudflareTurnResponse.iceServers().username(),
|
cloudflareTurnResponse.iceServers().username(),
|
||||||
cloudflareTurnResponse.iceServers().credential(),
|
cloudflareTurnResponse.iceServers().credential(),
|
||||||
|
clientCredentialTtl.toSeconds(),
|
||||||
cloudflareTurnUrls == null ? Collections.emptyList() : cloudflareTurnUrls,
|
cloudflareTurnUrls == null ? Collections.emptyList() : cloudflareTurnUrls,
|
||||||
cloudflareTurnComposedUrls,
|
cloudflareTurnComposedUrls,
|
||||||
cloudflareTurnHostname
|
cloudflareTurnHostname
|
||||||
|
|
|
@ -5,13 +5,15 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.auth;
|
package org.whispersystems.textsecuregcm.auth;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import java.util.List;
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record TurnToken(
|
public record TurnToken(
|
||||||
String username,
|
String username,
|
||||||
String password,
|
String password,
|
||||||
|
@JsonProperty("ttl") long ttlSeconds,
|
||||||
@Nonnull List<String> urls,
|
@Nonnull List<String> urls,
|
||||||
@Nonnull List<String> urlsWithIps,
|
@Nonnull List<String> urlsWithIps,
|
||||||
@Nullable String hostname) {
|
@Nullable String hostname) {
|
||||||
|
|
|
@ -6,16 +6,36 @@
|
||||||
package org.whispersystems.textsecuregcm.configuration;
|
package org.whispersystems.textsecuregcm.configuration;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration properties for Cloudflare TURN integration.
|
||||||
|
*
|
||||||
|
* @param apiToken the API token to use when requesting TURN tokens from Cloudflare
|
||||||
|
* @param endpoint the URI of the Cloudflare API endpoint that vends TURN tokens
|
||||||
|
* @param requestedCredentialTtl the lifetime of TURN tokens to request from Cloudflare
|
||||||
|
* @param clientCredentialTtl the time clients may cache a TURN token; must be less than or equal to {@link #requestedCredentialTtl}
|
||||||
|
* @param urls a collection of TURN URLs to include verbatim in responses to clients
|
||||||
|
* @param urlsWithIps a collection of {@link String#format(String, Object...)} patterns to be populated with resolved IP
|
||||||
|
* addresses for {@link #hostname} in responses to clients; each pattern must include a single
|
||||||
|
* {@code %s} placeholder for the IP address
|
||||||
|
* @param circuitBreaker a circuit breaker for requests to Cloudflare
|
||||||
|
* @param retry a retry policy for requests to Cloudflare
|
||||||
|
* @param hostname the hostname to resolve to IP addresses for use with {@link #urlsWithIps}; also transmitted to
|
||||||
|
* clients for use as an SNI when connecting to pre-resolved hosts
|
||||||
|
* @param numHttpClients the number of parallel HTTP clients to use to communicate with Cloudflare
|
||||||
|
*/
|
||||||
public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
|
public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
|
||||||
@NotBlank String endpoint,
|
@NotBlank String endpoint,
|
||||||
@NotBlank long ttl,
|
@NotNull Duration requestedCredentialTtl,
|
||||||
|
@NotNull Duration clientCredentialTtl,
|
||||||
@NotNull @NotEmpty @Valid List<@NotBlank String> urls,
|
@NotNull @NotEmpty @Valid List<@NotBlank String> urls,
|
||||||
@NotNull @NotEmpty @Valid List<@NotBlank String> urlsWithIps,
|
@NotNull @NotEmpty @Valid List<@NotBlank String> urlsWithIps,
|
||||||
@NotNull @Valid CircuitBreakerConfiguration circuitBreaker,
|
@NotNull @Valid CircuitBreakerConfiguration circuitBreaker,
|
||||||
|
@ -35,4 +55,9 @@ public record CloudflareTurnConfiguration(@NotNull SecretString apiToken,
|
||||||
retry = new RetryConfiguration();
|
retry = new RetryConfiguration();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AssertTrue
|
||||||
|
public boolean isClientTtlShorterThanRequestedTtl() {
|
||||||
|
return clientCredentialTtl.compareTo(requestedCredentialTtl) <= 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,16 +15,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
|
||||||
import jakarta.ws.rs.core.Context;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;
|
||||||
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
|
import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;
|
||||||
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
|
||||||
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
import org.whispersystems.textsecuregcm.limits.RateLimiters;
|
||||||
import org.whispersystems.websocket.auth.ReadOnly;
|
import org.whispersystems.websocket.auth.ReadOnly;
|
||||||
|
|
||||||
|
@ -32,14 +28,16 @@ import org.whispersystems.websocket.auth.ReadOnly;
|
||||||
@Path("/v2/calling")
|
@Path("/v2/calling")
|
||||||
public class CallRoutingControllerV2 {
|
public class CallRoutingControllerV2 {
|
||||||
|
|
||||||
private static final Counter CLOUDFLARE_TURN_ERROR_COUNTER = Metrics.counter(name(CallRoutingControllerV2.class, "cloudflareTurnError"));
|
|
||||||
private final RateLimiters rateLimiters;
|
private final RateLimiters rateLimiters;
|
||||||
private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
|
private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
|
||||||
|
|
||||||
|
private static final Counter CLOUDFLARE_TURN_ERROR_COUNTER =
|
||||||
|
Metrics.counter(name(CallRoutingControllerV2.class, "cloudflareTurnError"));
|
||||||
|
|
||||||
public CallRoutingControllerV2(
|
public CallRoutingControllerV2(
|
||||||
final RateLimiters rateLimiters,
|
final RateLimiters rateLimiters,
|
||||||
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager
|
final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager) {
|
||||||
) {
|
|
||||||
this.rateLimiters = rateLimiters;
|
this.rateLimiters = rateLimiters;
|
||||||
this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;
|
this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;
|
||||||
}
|
}
|
||||||
|
@ -58,25 +56,17 @@ public class CallRoutingControllerV2 {
|
||||||
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
@ApiResponse(responseCode = "401", description = "Account authentication check failed.")
|
||||||
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
@ApiResponse(responseCode = "422", description = "Invalid request format.")
|
||||||
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
@ApiResponse(responseCode = "429", description = "Rate limited.")
|
||||||
public GetCallingRelaysResponse getCallingRelays(
|
public GetCallingRelaysResponse getCallingRelays(final @ReadOnly @Auth AuthenticatedDevice auth)
|
||||||
final @ReadOnly @Auth AuthenticatedDevice auth
|
throws RateLimitExceededException, IOException {
|
||||||
) throws RateLimitExceededException, IOException {
|
|
||||||
UUID aci = auth.getAccount().getUuid();
|
final UUID aci = auth.getAccount().getUuid();
|
||||||
rateLimiters.getCallEndpointLimiter().validate(aci);
|
rateLimiters.getCallEndpointLimiter().validate(aci);
|
||||||
|
|
||||||
List<TurnToken> tokens = new ArrayList<>();
|
|
||||||
try {
|
try {
|
||||||
tokens.add(cloudflareTurnCredentialsManager.retrieveFromCloudflare());
|
return new GetCallingRelaysResponse(List.of(cloudflareTurnCredentialsManager.retrieveFromCloudflare()));
|
||||||
} catch (Exception e) {
|
} catch (final Exception e) {
|
||||||
CallRoutingControllerV2.CLOUDFLARE_TURN_ERROR_COUNTER.increment();
|
CLOUDFLARE_TURN_ERROR_COUNTER.increment();
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new GetCallingRelaysResponse(tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
public record GetCallingRelaysResponse(
|
|
||||||
List<TurnToken> relays
|
|
||||||
) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 Signal Messenger, LLC
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.whispersystems.textsecuregcm.controllers;
|
||||||
|
|
||||||
|
import org.whispersystems.textsecuregcm.auth.TurnToken;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record GetCallingRelaysResponse(List<TurnToken> relays) {
|
||||||
|
}
|
|
@ -5,8 +5,11 @@
|
||||||
|
|
||||||
package org.whispersystems.textsecuregcm.auth;
|
package org.whispersystems.textsecuregcm.auth;
|
||||||
|
|
||||||
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
|
import static com.github.tomakehurst.wiremock.client.WireMock.created;
|
||||||
|
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
|
||||||
|
import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
|
||||||
import static com.github.tomakehurst.wiremock.client.WireMock.post;
|
import static com.github.tomakehurst.wiremock.client.WireMock.post;
|
||||||
|
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
|
||||||
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
|
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
|
||||||
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
|
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
@ -15,17 +18,19 @@ import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
|
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
|
||||||
import io.netty.resolver.dns.DnsNameResolver;
|
import io.netty.resolver.dns.DnsNameResolver;
|
||||||
import io.netty.util.concurrent.Future;
|
import io.netty.util.concurrent.GlobalEventExecutor;
|
||||||
|
import io.netty.util.concurrent.SucceededFuture;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.security.cert.CertificateException;
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CancellationException;
|
import java.util.concurrent.CancellationException;
|
||||||
import java.util.concurrent.ExecutionException;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.apache.commons.lang3.RandomStringUtils;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
@ -35,31 +40,41 @@ import org.whispersystems.textsecuregcm.configuration.RetryConfiguration;
|
||||||
|
|
||||||
public class CloudflareTurnCredentialsManagerTest {
|
public class CloudflareTurnCredentialsManagerTest {
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
private final WireMockExtension wireMock = WireMockExtension.newInstance()
|
private static final WireMockExtension wireMock = WireMockExtension.newInstance()
|
||||||
.options(wireMockConfig().dynamicPort().dynamicHttpsPort())
|
.options(wireMockConfig().dynamicPort().dynamicHttpsPort())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private static final String GET_CREDENTIALS_PATH = "/v1/turn/keys/LMNOP/credentials/generate";
|
|
||||||
private static final String TURN_HOSTNAME = "localhost";
|
|
||||||
private ExecutorService httpExecutor;
|
private ExecutorService httpExecutor;
|
||||||
private ScheduledExecutorService retryExecutor;
|
private ScheduledExecutorService retryExecutor;
|
||||||
private DnsNameResolver dnsResolver;
|
private DnsNameResolver dnsResolver;
|
||||||
private Future<List<InetAddress>> dnsResult;
|
|
||||||
|
|
||||||
private CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = null;
|
private CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;
|
||||||
|
|
||||||
|
private static final String GET_CREDENTIALS_PATH = "/v1/turn/keys/LMNOP/credentials/generate";
|
||||||
|
private static final String TURN_HOSTNAME = "localhost";
|
||||||
|
|
||||||
|
private static final String API_TOKEN = RandomStringUtils.insecure().nextAlphanumeric(16);
|
||||||
|
private static final String USERNAME = RandomStringUtils.insecure().nextAlphanumeric(16);
|
||||||
|
private static final String CREDENTIAL = RandomStringUtils.insecure().nextAlphanumeric(16);
|
||||||
|
private static final List<String> CLOUDFLARE_TURN_URLS = List.of("turn:cf.example.com");
|
||||||
|
private static final Duration REQUESTED_CREDENTIAL_TTL = Duration.ofSeconds(100);
|
||||||
|
private static final Duration CLIENT_CREDENTIAL_TTL = REQUESTED_CREDENTIAL_TTL.dividedBy(2);
|
||||||
|
private static final List<String> IP_URL_PATTERNS = List.of("turn:%s", "turn:%s:80?transport=tcp", "turns:%s:443?transport=tcp");
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() throws CertificateException {
|
void setUp() {
|
||||||
httpExecutor = Executors.newSingleThreadExecutor();
|
httpExecutor = Executors.newSingleThreadExecutor();
|
||||||
retryExecutor = Executors.newSingleThreadScheduledExecutor();
|
retryExecutor = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
|
||||||
dnsResolver = mock(DnsNameResolver.class);
|
dnsResolver = mock(DnsNameResolver.class);
|
||||||
dnsResult = mock(Future.class);
|
|
||||||
cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
|
cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(
|
||||||
"API_TOKEN",
|
API_TOKEN,
|
||||||
"http://localhost:" + wireMock.getPort() + GET_CREDENTIALS_PATH,
|
"http://localhost:" + wireMock.getPort() + GET_CREDENTIALS_PATH,
|
||||||
100,
|
REQUESTED_CREDENTIAL_TTL,
|
||||||
List.of("turn:cf.example.com"),
|
CLIENT_CREDENTIAL_TTL,
|
||||||
List.of("turn:%s", "turn:%s:80?transport=tcp", "turns:%s:443?transport=tcp"),
|
CLOUDFLARE_TURN_URLS,
|
||||||
|
IP_URL_PATTERNS,
|
||||||
TURN_HOSTNAME,
|
TURN_HOSTNAME,
|
||||||
2,
|
2,
|
||||||
new CircuitBreakerConfiguration(),
|
new CircuitBreakerConfiguration(),
|
||||||
|
@ -73,26 +88,61 @@ public class CloudflareTurnCredentialsManagerTest {
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void tearDown() throws InterruptedException {
|
void tearDown() throws InterruptedException {
|
||||||
httpExecutor.shutdown();
|
httpExecutor.shutdown();
|
||||||
httpExecutor.awaitTermination(1, TimeUnit.SECONDS);
|
|
||||||
retryExecutor.shutdown();
|
retryExecutor.shutdown();
|
||||||
|
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
httpExecutor.awaitTermination(1, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
retryExecutor.awaitTermination(1, TimeUnit.SECONDS);
|
retryExecutor.awaitTermination(1, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testSuccess() throws IOException, CancellationException, ExecutionException, InterruptedException {
|
public void testSuccess() throws IOException, CancellationException {
|
||||||
wireMock.stubFor(post(urlEqualTo(GET_CREDENTIALS_PATH))
|
wireMock.stubFor(post(urlEqualTo(GET_CREDENTIALS_PATH))
|
||||||
.willReturn(aResponse().withStatus(201).withHeader("Content-Type", new String[]{"application/json"}).withBody("{\"iceServers\":{\"urls\":[\"turn:cloudflare.example.com:3478?transport=udp\"],\"username\":\"ABC\",\"credential\":\"XYZ\"}}")));
|
.willReturn(created()
|
||||||
when(dnsResult.get())
|
.withHeader("Content-Type", "application/json")
|
||||||
.thenReturn(List.of(InetAddress.getByName("127.0.0.1"), InetAddress.getByName("::1")));
|
.withBody("""
|
||||||
|
{
|
||||||
|
"iceServers": {
|
||||||
|
"urls": [
|
||||||
|
"turn:cloudflare.example.com:3478?transport=udp"
|
||||||
|
],
|
||||||
|
"username": "%s",
|
||||||
|
"credential": "%s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".formatted(USERNAME, CREDENTIAL))));
|
||||||
|
|
||||||
when(dnsResolver.resolveAll(TURN_HOSTNAME))
|
when(dnsResolver.resolveAll(TURN_HOSTNAME))
|
||||||
.thenReturn(dnsResult);
|
.thenReturn(new SucceededFuture<>(GlobalEventExecutor.INSTANCE,
|
||||||
|
List.of(InetAddress.getByName("127.0.0.1"), InetAddress.getByName("::1"))));
|
||||||
|
|
||||||
TurnToken token = cloudflareTurnCredentialsManager.retrieveFromCloudflare();
|
TurnToken token = cloudflareTurnCredentialsManager.retrieveFromCloudflare();
|
||||||
|
|
||||||
assertThat(token.username()).isEqualTo("ABC");
|
wireMock.verify(postRequestedFor(urlEqualTo(GET_CREDENTIALS_PATH))
|
||||||
assertThat(token.password()).isEqualTo("XYZ");
|
.withHeader("Content-Type", equalTo("application/json"))
|
||||||
assertThat(token.hostname()).isEqualTo("localhost");
|
.withHeader("Authorization", equalTo("Bearer " + API_TOKEN))
|
||||||
assertThat(token.urlsWithIps()).containsAll(List.of("turn:127.0.0.1", "turn:127.0.0.1:80?transport=tcp", "turns:127.0.0.1:443?transport=tcp", "turn:[0:0:0:0:0:0:0:1]", "turn:[0:0:0:0:0:0:0:1]:80?transport=tcp", "turns:[0:0:0:0:0:0:0:1]:443?transport=tcp"));;
|
.withRequestBody(equalToJson("""
|
||||||
assertThat(token.urls()).isEqualTo(List.of("turn:cf.example.com"));
|
{
|
||||||
|
"ttl": %d
|
||||||
|
}
|
||||||
|
""".formatted(REQUESTED_CREDENTIAL_TTL.toSeconds()))));
|
||||||
|
|
||||||
|
assertThat(token.username()).isEqualTo(USERNAME);
|
||||||
|
assertThat(token.password()).isEqualTo(CREDENTIAL);
|
||||||
|
assertThat(token.hostname()).isEqualTo(TURN_HOSTNAME);
|
||||||
|
assertThat(token.urls()).isEqualTo(CLOUDFLARE_TURN_URLS);
|
||||||
|
assertThat(token.ttlSeconds()).isEqualTo(CLIENT_CREDENTIAL_TTL.toSeconds());
|
||||||
|
|
||||||
|
final List<String> expectedUrlsWithIps = new ArrayList<>();
|
||||||
|
|
||||||
|
for (final String ip : new String[] {"127.0.0.1", "[0:0:0:0:0:0:0:1]"}) {
|
||||||
|
for (final String pattern : IP_URL_PATTERNS) {
|
||||||
|
expectedUrlsWithIps.add(pattern.formatted(ip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(token.urlsWithIps()).containsExactlyElementsOf(expectedUrlsWithIps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,14 +40,15 @@ class CallRoutingControllerV2Test {
|
||||||
private static final TurnToken CLOUDFLARE_TURN_TOKEN = new TurnToken(
|
private static final TurnToken CLOUDFLARE_TURN_TOKEN = new TurnToken(
|
||||||
"ABC",
|
"ABC",
|
||||||
"XYZ",
|
"XYZ",
|
||||||
|
43_200,
|
||||||
List.of("turn:cloudflare.example.com:3478?transport=udp"),
|
List.of("turn:cloudflare.example.com:3478?transport=udp"),
|
||||||
null,
|
null,
|
||||||
"cf.example.com");
|
"cf.example.com");
|
||||||
|
|
||||||
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
|
private static final RateLimiters rateLimiters = mock(RateLimiters.class);
|
||||||
private static final RateLimiter getCallEndpointLimiter = mock(RateLimiter.class);
|
private static final RateLimiter getCallEndpointLimiter = mock(RateLimiter.class);
|
||||||
private static final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = mock(
|
private static final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager =
|
||||||
CloudflareTurnCredentialsManager.class);
|
mock(CloudflareTurnCredentialsManager.class);
|
||||||
|
|
||||||
private static final ResourceExtension resources = ResourceExtension.builder()
|
private static final ResourceExtension resources = ResourceExtension.builder()
|
||||||
.addProvider(AuthHelper.getAuthFilter())
|
.addProvider(AuthHelper.getAuthFilter())
|
||||||
|
@ -66,21 +67,14 @@ class CallRoutingControllerV2Test {
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void tearDown() {
|
void tearDown() {
|
||||||
reset( rateLimiters, getCallEndpointLimiter);
|
reset(rateLimiters, getCallEndpointLimiter);
|
||||||
}
|
|
||||||
|
|
||||||
void initializeMocksWith(TurnToken cloudflareToken) {
|
|
||||||
try {
|
|
||||||
when(cloudflareTurnCredentialsManager.retrieveFromCloudflare()).thenReturn(cloudflareToken);
|
|
||||||
} catch (IOException ignored) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGetRelaysBothRouting() {
|
void testGetRelaysBothRouting() throws IOException {
|
||||||
initializeMocksWith(CLOUDFLARE_TURN_TOKEN);
|
when(cloudflareTurnCredentialsManager.retrieveFromCloudflare()).thenReturn(CLOUDFLARE_TURN_TOKEN);
|
||||||
|
|
||||||
try (Response rawResponse = resources.getJerseyTest()
|
try (final Response rawResponse = resources.getJerseyTest()
|
||||||
.target(GET_CALL_RELAYS_PATH)
|
.target(GET_CALL_RELAYS_PATH)
|
||||||
.request()
|
.request()
|
||||||
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
.header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))
|
||||||
|
@ -88,11 +82,8 @@ class CallRoutingControllerV2Test {
|
||||||
|
|
||||||
assertThat(rawResponse.getStatus()).isEqualTo(200);
|
assertThat(rawResponse.getStatus()).isEqualTo(200);
|
||||||
|
|
||||||
CallRoutingControllerV2.GetCallingRelaysResponse response = rawResponse.readEntity(
|
assertThat(rawResponse.readEntity(GetCallingRelaysResponse.class).relays())
|
||||||
CallRoutingControllerV2.GetCallingRelaysResponse.class);
|
.isEqualTo(List.of(CLOUDFLARE_TURN_TOKEN));
|
||||||
|
|
||||||
List<TurnToken> relays = response.relays();
|
|
||||||
assertThat(relays).isEqualTo(List.of(CLOUDFLARE_TURN_TOKEN));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -470,7 +470,8 @@ turn:
|
||||||
cloudflare:
|
cloudflare:
|
||||||
apiToken: secret://turn.cloudflare.apiToken
|
apiToken: secret://turn.cloudflare.apiToken
|
||||||
endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate
|
endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate
|
||||||
ttl: 86400
|
requestedCredentialTtl: PT24H
|
||||||
|
clientCredentialTtl: PT12H
|
||||||
urls:
|
urls:
|
||||||
- turn:turn.example.com:80
|
- turn:turn.example.com:80
|
||||||
urlsWithIps:
|
urlsWithIps:
|
||||||
|
|
Loading…
Reference in New Issue