diff --git a/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/ApiKeyAuthenticationRequestFilter.kt b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/ApiKeyAuthenticationRequestFilter.kt new file mode 100644 index 0000000..ffa387b --- /dev/null +++ b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/ApiKeyAuthenticationRequestFilter.kt @@ -0,0 +1,16 @@ +package com.faforever.icebreaker.service.cloudflare + +import jakarta.annotation.Priority +import jakarta.ws.rs.Priorities +import jakarta.ws.rs.client.ClientRequestContext +import jakarta.ws.rs.client.ClientRequestFilter +import jakarta.ws.rs.core.HttpHeaders + +@Priority(Priorities.AUTHENTICATION) +class ApiKeyAuthenticationRequestFilter(turnApiKey: String) : ClientRequestFilter { + override fun filter(requestContext: ClientRequestContext) { + requestContext.headers.add(HttpHeaders.AUTHORIZATION, accessToken) + } + + private val accessToken: String = "Bearer $turnApiKey" +} diff --git a/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareApiAdapter.kt b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareApiAdapter.kt new file mode 100644 index 0000000..f69095a --- /dev/null +++ b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareApiAdapter.kt @@ -0,0 +1,24 @@ +package com.faforever.icebreaker.service.cloudflare + +import jakarta.inject.Singleton +import org.eclipse.microprofile.faulttolerance.Retry +import org.eclipse.microprofile.rest.client.RestClientBuilder +import java.net.URI + +@Singleton +class CloudflareApiAdapter( + private val cloudflareProperties: CloudflareProperties, +) { + private val cloudflareApiClient = RestClientBuilder + .newBuilder() + .baseUri(URI.create("https://rtc.live.cloudflare.com")) + .register(ApiKeyAuthenticationRequestFilter(turnApiKey = cloudflareProperties.turnKeyApiToken())) + .build(CloudflareApiClient::class.java) + + @Retry + fun requestIceServers(credentialRequest: CloudflareApiClient.CredentialRequest) = + cloudflareApiClient.requestCredentials( + turnKeyId = cloudflareProperties.turnKeyId(), + credentialRequest = credentialRequest + ) +} diff --git a/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareApiClient.kt b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareApiClient.kt new file mode 100644 index 0000000..8faf6b9 --- /dev/null +++ b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareApiClient.kt @@ -0,0 +1,27 @@ +package com.faforever.icebreaker.service.cloudflare + +import jakarta.enterprise.context.ApplicationScoped +import jakarta.ws.rs.Consumes +import jakarta.ws.rs.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.core.MediaType +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient + +@ApplicationScoped +@RegisterRestClient +@Consumes(MediaType.APPLICATION_JSON) +interface CloudflareApiClient { + + data class CredentialRequest(val ttl: Long) + data class CredentialResponse(val iceServers: IceServers) { + data class IceServers(val urls: List, val username: String, val credential: String) + } + + @POST + @Path("/v1/turn/keys/{turnKeyId}/credentials/generate") + fun requestCredentials( + @PathParam("turnKeyId") turnKeyId: String, + credentialRequest: CredentialRequest + ): CredentialResponse +} diff --git a/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareProperties.kt b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareProperties.kt new file mode 100644 index 0000000..fc10e1e --- /dev/null +++ b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareProperties.kt @@ -0,0 +1,14 @@ +package com.faforever.icebreaker.service.cloudflare + +import io.smallrye.config.ConfigMapping + +@ConfigMapping(prefix = "cloudflare") +interface CloudflareProperties { + fun enabled(): Boolean + + fun turnEnabled(): Boolean + + fun turnKeyId(): String + + fun turnKeyApiToken(): String +} diff --git a/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareSessionHandler.kt b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareSessionHandler.kt new file mode 100644 index 0000000..d2a0653 --- /dev/null +++ b/src/main/kotlin/com/faforever/icebreaker/service/cloudflare/CloudflareSessionHandler.kt @@ -0,0 +1,50 @@ +package com.faforever.icebreaker.service.cloudflare + +import com.faforever.icebreaker.config.FafProperties +import com.faforever.icebreaker.service.Server +import com.faforever.icebreaker.service.Session +import com.faforever.icebreaker.service.SessionHandler +import jakarta.inject.Singleton + +@Singleton +class CloudflareSessionHandler( + cloudflareProperties: CloudflareProperties, + val fafProperties: FafProperties, + private val cloudflareApiAdapter: CloudflareApiAdapter, +) : SessionHandler { + companion object { + const val SERVER_NAME = "cloudflare.com" + } + + override val active = cloudflareProperties.enabled() + private val turnEnabled = cloudflareProperties.turnEnabled() + + override fun createSession(id: String) { + // Cloudflare has no session handling, we use global access + } + + override fun deleteSession(id: String) { + // Cloudflare has no session handling, we use global access + } + + override fun getIceServers() = listOf(Server(id = SERVER_NAME, region = "Global")) + + override fun getIceServersSession(sessionId: String): List = + cloudflareApiAdapter.requestIceServers( + credentialRequest = CloudflareApiClient.CredentialRequest(ttl = fafProperties.tokenLifetimeSeconds()), + ).let { + listOf( + Session.Server( + id = SERVER_NAME, + username = it.iceServers.username, + credential = it.iceServers.credential, + urls = it.iceServers.urls.map { url -> + // A sample response looks like "stun:fr-turn1.xirsys.com" + // The java URI class fails to read host and port due to the missing // after the : + // Thus we "normalize" the uri, even though it is technically valid + url.replaceFirst(":", "://") + }.filter { url -> turnEnabled || !url.startsWith("turn") }, + ), + ) + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 549a502..0e7f1f4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -51,6 +51,11 @@ xirsys: channel-namespace: "faf" turn-enabled: ${XIRSYS_TURN_ENABLED:true} geo-ip-path: ${GEO_IP_DATABASE_PATH:/geoip/GeoLite2-City.mmdb} +cloudflare: + enabled: ${CLOUDFLARE_ENABLED:true} + turn-enabled: ${CLOUDFLARE_TURN_ENABLED:true} + turn-key-id: ${CLOUDFLARE_TURN_KEY_ID:undefined} + turn-key-api-token: ${CLOUDFLARE_TURN_KEY_API_KEY:undefined} smallrye: jwt: sign: @@ -58,6 +63,8 @@ smallrye: "%dev": xirsys: enabled: ${XIRSYS_ENABLED:false} + cloudflare: + enabled: ${CLOUDFLARE_ENABLED:false} smallrye: jwt: sign: