diff --git a/service/config/sample.yml b/service/config/sample.yml index 43e3c6444..e45c7cfd8 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -482,7 +482,8 @@ turn: - turn:%s - turn:%s:80?transport=tcp - turns:%s:443?transport=tcp - ttl: 86400 + requestedCredentialTtl: PT24H + clientCredentialTtl: PT12H hostname: turn.cloudflare.example.com numHttpClients: 1 diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 3fb4fa2c2..fb337ae1c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -673,7 +673,8 @@ public class WhisperServerService extends Application cloudflareTurnUrls; private final List cloudflareTurnUrlsWithIps; private final String cloudflareTurnHostname; - private final HttpRequest request; + private final HttpRequest getCredentialsRequest; private final FaultTolerantHttpClient cloudflareTurnClient; 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 credential, List urls) { @@ -56,10 +59,17 @@ public class CloudflareTurnCredentialsManager { } public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken, - final String cloudflareTurnEndpoint, final long cloudflareTurnTtl, final List cloudflareTurnUrls, - final List cloudflareTurnUrlsWithIps, final String cloudflareTurnHostname, - final int cloudflareTurnNumHttpClients, final CircuitBreakerConfiguration circuitBreaker, - final ExecutorService executor, final RetryConfiguration retry, final ScheduledExecutorService retryExecutor, + final String cloudflareTurnEndpoint, + final Duration requestedCredentialTtl, + final Duration clientCredentialTtl, + final List cloudflareTurnUrls, + final List cloudflareTurnUrlsWithIps, + final String cloudflareTurnHostname, + final int cloudflareTurnNumHttpClients, + final CircuitBreakerConfiguration circuitBreaker, + final ExecutorService executor, + final RetryConfiguration retry, + final ScheduledExecutorService retryExecutor, final DnsNameResolver dnsNameResolver) { this.cloudflareTurnClient = FaultTolerantHttpClient.newBuilder() @@ -75,17 +85,24 @@ public class CloudflareTurnCredentialsManager { this.cloudflareTurnHostname = cloudflareTurnHostname; this.dnsNameResolver = dnsNameResolver; + final String credentialsRequestBody; + try { - final String body = SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(cloudflareTurnTtl)); - this.request = HttpRequest.newBuilder() - .uri(URI.create(cloudflareTurnEndpoint)) - .header("Content-Type", "application/json") - .header("Authorization", String.format("Bearer %s", cloudflareTurnApiToken)) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); - } catch (JsonProcessingException e) { + credentialsRequestBody = + SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(requestedCredentialTtl.toSeconds())); + } catch (final JsonProcessingException 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 { @@ -105,7 +122,7 @@ public class CloudflareTurnCredentialsManager { final Timer.Sample sample = Timer.start(); final HttpResponse response; 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) .publishPercentileHistogram(true) .tags("outcome", "success") @@ -130,6 +147,7 @@ public class CloudflareTurnCredentialsManager { return new TurnToken( cloudflareTurnResponse.iceServers().username(), cloudflareTurnResponse.iceServers().credential(), + clientCredentialTtl.toSeconds(), cloudflareTurnUrls == null ? Collections.emptyList() : cloudflareTurnUrls, cloudflareTurnComposedUrls, cloudflareTurnHostname diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnToken.java b/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnToken.java index 98ddace09..3536df03b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnToken.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnToken.java @@ -5,13 +5,15 @@ package org.whispersystems.textsecuregcm.auth; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.List; public record TurnToken( String username, String password, + @JsonProperty("ttl") long ttlSeconds, @Nonnull List urls, @Nonnull List urlsWithIps, @Nullable String hostname) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java index d7fd73e0e..f4154bac5 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java @@ -6,16 +6,36 @@ package org.whispersystems.textsecuregcm.configuration; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import java.time.Duration; import java.util.List; import jakarta.validation.constraints.Positive; 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, @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> urlsWithIps, @NotNull @Valid CircuitBreakerConfiguration circuitBreaker, @@ -35,4 +55,9 @@ public record CloudflareTurnConfiguration(@NotNull SecretString apiToken, retry = new RetryConfiguration(); } } + + @AssertTrue + public boolean isClientTtlShorterThanRequestedTtl() { + return clientCredentialTtl.compareTo(requestedCredentialTtl) <= 0; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java index 57e92728e..e90549934 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java @@ -15,16 +15,12 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import java.io.IOException; -import java.util.ArrayList; import java.util.List; import java.util.UUID; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager; -import org.whispersystems.textsecuregcm.auth.TurnToken; import org.whispersystems.textsecuregcm.limits.RateLimiters; import org.whispersystems.websocket.auth.ReadOnly; @@ -32,14 +28,16 @@ import org.whispersystems.websocket.auth.ReadOnly; @Path("/v2/calling") public class CallRoutingControllerV2 { - private static final Counter CLOUDFLARE_TURN_ERROR_COUNTER = Metrics.counter(name(CallRoutingControllerV2.class, "cloudflareTurnError")); private final RateLimiters rateLimiters; private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager; + private static final Counter CLOUDFLARE_TURN_ERROR_COUNTER = + Metrics.counter(name(CallRoutingControllerV2.class, "cloudflareTurnError")); + public CallRoutingControllerV2( final RateLimiters rateLimiters, - final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager - ) { + final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager) { + this.rateLimiters = rateLimiters; this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager; } @@ -58,25 +56,17 @@ public class CallRoutingControllerV2 { @ApiResponse(responseCode = "401", description = "Account authentication check failed.") @ApiResponse(responseCode = "422", description = "Invalid request format.") @ApiResponse(responseCode = "429", description = "Rate limited.") - public GetCallingRelaysResponse getCallingRelays( - final @ReadOnly @Auth AuthenticatedDevice auth - ) throws RateLimitExceededException, IOException { - UUID aci = auth.getAccount().getUuid(); + public GetCallingRelaysResponse getCallingRelays(final @ReadOnly @Auth AuthenticatedDevice auth) + throws RateLimitExceededException, IOException { + + final UUID aci = auth.getAccount().getUuid(); rateLimiters.getCallEndpointLimiter().validate(aci); - List tokens = new ArrayList<>(); try { - tokens.add(cloudflareTurnCredentialsManager.retrieveFromCloudflare()); - } catch (Exception e) { - CallRoutingControllerV2.CLOUDFLARE_TURN_ERROR_COUNTER.increment(); + return new GetCallingRelaysResponse(List.of(cloudflareTurnCredentialsManager.retrieveFromCloudflare())); + } catch (final Exception e) { + CLOUDFLARE_TURN_ERROR_COUNTER.increment(); throw e; } - - return new GetCallingRelaysResponse(tokens); - } - - public record GetCallingRelaysResponse( - List relays - ) { } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/GetCallingRelaysResponse.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/GetCallingRelaysResponse.java new file mode 100644 index 000000000..753043e9d --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/GetCallingRelaysResponse.java @@ -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 relays) { +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java index e5b814a72..a5a07fb3f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java @@ -5,8 +5,11 @@ 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.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static org.assertj.core.api.Assertions.assertThat; @@ -15,17 +18,19 @@ import static org.mockito.Mockito.when; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; import io.netty.resolver.dns.DnsNameResolver; -import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.GlobalEventExecutor; +import io.netty.util.concurrent.SucceededFuture; import java.io.IOException; import java.net.InetAddress; -import java.security.cert.CertificateException; +import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,31 +40,41 @@ import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; public class CloudflareTurnCredentialsManagerTest { @RegisterExtension - private final WireMockExtension wireMock = WireMockExtension.newInstance() + private static final WireMockExtension wireMock = WireMockExtension.newInstance() .options(wireMockConfig().dynamicPort().dynamicHttpsPort()) .build(); - private static final String GET_CREDENTIALS_PATH = "/v1/turn/keys/LMNOP/credentials/generate"; - private static final String TURN_HOSTNAME = "localhost"; private ExecutorService httpExecutor; private ScheduledExecutorService retryExecutor; private DnsNameResolver dnsResolver; - private Future> 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 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 IP_URL_PATTERNS = List.of("turn:%s", "turn:%s:80?transport=tcp", "turns:%s:443?transport=tcp"); @BeforeEach - void setUp() throws CertificateException { + void setUp() { httpExecutor = Executors.newSingleThreadExecutor(); retryExecutor = Executors.newSingleThreadScheduledExecutor(); + dnsResolver = mock(DnsNameResolver.class); - dnsResult = mock(Future.class); + cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager( - "API_TOKEN", + API_TOKEN, "http://localhost:" + wireMock.getPort() + GET_CREDENTIALS_PATH, - 100, - List.of("turn:cf.example.com"), - List.of("turn:%s", "turn:%s:80?transport=tcp", "turns:%s:443?transport=tcp"), + REQUESTED_CREDENTIAL_TTL, + CLIENT_CREDENTIAL_TTL, + CLOUDFLARE_TURN_URLS, + IP_URL_PATTERNS, TURN_HOSTNAME, 2, new CircuitBreakerConfiguration(), @@ -73,26 +88,61 @@ public class CloudflareTurnCredentialsManagerTest { @AfterEach void tearDown() throws InterruptedException { httpExecutor.shutdown(); - httpExecutor.awaitTermination(1, TimeUnit.SECONDS); retryExecutor.shutdown(); + + //noinspection ResultOfMethodCallIgnored + httpExecutor.awaitTermination(1, TimeUnit.SECONDS); + + //noinspection ResultOfMethodCallIgnored retryExecutor.awaitTermination(1, TimeUnit.SECONDS); } @Test - public void testSuccess() throws IOException, CancellationException, ExecutionException, InterruptedException { + public void testSuccess() throws IOException, CancellationException { wireMock.stubFor(post(urlEqualTo(GET_CREDENTIALS_PATH)) - .willReturn(aResponse().withStatus(201).withHeader("Content-Type", new String[]{"application/json"}).withBody("{\"iceServers\":{\"urls\":[\"turn:cloudflare.example.com:3478?transport=udp\"],\"username\":\"ABC\",\"credential\":\"XYZ\"}}"))); - when(dnsResult.get()) - .thenReturn(List.of(InetAddress.getByName("127.0.0.1"), InetAddress.getByName("::1"))); + .willReturn(created() + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "iceServers": { + "urls": [ + "turn:cloudflare.example.com:3478?transport=udp" + ], + "username": "%s", + "credential": "%s" + } + } + """.formatted(USERNAME, CREDENTIAL)))); + 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(); - assertThat(token.username()).isEqualTo("ABC"); - assertThat(token.password()).isEqualTo("XYZ"); - assertThat(token.hostname()).isEqualTo("localhost"); - assertThat(token.urlsWithIps()).containsAll(List.of("turn:127.0.0.1", "turn:127.0.0.1:80?transport=tcp", "turns:127.0.0.1:443?transport=tcp", "turn:[0:0:0:0:0:0:0:1]", "turn:[0:0:0:0:0:0:0:1]:80?transport=tcp", "turns:[0:0:0:0:0:0:0:1]:443?transport=tcp"));; - assertThat(token.urls()).isEqualTo(List.of("turn:cf.example.com")); + wireMock.verify(postRequestedFor(urlEqualTo(GET_CREDENTIALS_PATH)) + .withHeader("Content-Type", equalTo("application/json")) + .withHeader("Authorization", equalTo("Bearer " + API_TOKEN)) + .withRequestBody(equalToJson(""" + { + "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 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); } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2Test.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2Test.java index 3be9dc32b..03d7dd4ce 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2Test.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2Test.java @@ -40,14 +40,15 @@ class CallRoutingControllerV2Test { private static final TurnToken CLOUDFLARE_TURN_TOKEN = new TurnToken( "ABC", "XYZ", + 43_200, List.of("turn:cloudflare.example.com:3478?transport=udp"), null, "cf.example.com"); private static final RateLimiters rateLimiters = mock(RateLimiters.class); private static final RateLimiter getCallEndpointLimiter = mock(RateLimiter.class); - private static final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = mock( - CloudflareTurnCredentialsManager.class); + private static final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = + mock(CloudflareTurnCredentialsManager.class); private static final ResourceExtension resources = ResourceExtension.builder() .addProvider(AuthHelper.getAuthFilter()) @@ -66,21 +67,14 @@ class CallRoutingControllerV2Test { @AfterEach void tearDown() { - reset( rateLimiters, getCallEndpointLimiter); - } - - void initializeMocksWith(TurnToken cloudflareToken) { - try { - when(cloudflareTurnCredentialsManager.retrieveFromCloudflare()).thenReturn(cloudflareToken); - } catch (IOException ignored) { - } + reset(rateLimiters, getCallEndpointLimiter); } @Test - void testGetRelaysBothRouting() { - initializeMocksWith(CLOUDFLARE_TURN_TOKEN); + void testGetRelaysBothRouting() throws IOException { + when(cloudflareTurnCredentialsManager.retrieveFromCloudflare()).thenReturn(CLOUDFLARE_TURN_TOKEN); - try (Response rawResponse = resources.getJerseyTest() + try (final Response rawResponse = resources.getJerseyTest() .target(GET_CALL_RELAYS_PATH) .request() .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) @@ -88,11 +82,8 @@ class CallRoutingControllerV2Test { assertThat(rawResponse.getStatus()).isEqualTo(200); - CallRoutingControllerV2.GetCallingRelaysResponse response = rawResponse.readEntity( - CallRoutingControllerV2.GetCallingRelaysResponse.class); - - List relays = response.relays(); - assertThat(relays).isEqualTo(List.of(CLOUDFLARE_TURN_TOKEN)); + assertThat(rawResponse.readEntity(GetCallingRelaysResponse.class).relays()) + .isEqualTo(List.of(CLOUDFLARE_TURN_TOKEN)); } } diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 208959b1e..021cf1f5b 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -470,7 +470,8 @@ turn: cloudflare: apiToken: secret://turn.cloudflare.apiToken endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate - ttl: 86400 + requestedCredentialTtl: PT24H + clientCredentialTtl: PT12H urls: - turn:turn.example.com:80 urlsWithIps: