From b8cacaed97480aa8945050a648132c9218b357c4 Mon Sep 17 00:00:00 2001 From: Brutus5000 Date: Tue, 2 Jul 2024 10:18:45 +0200 Subject: [PATCH] Pick closest Xirsys server based on users geo location --- .editorconfig | 12 +++++ README.md | 4 +- build.gradle.kts | 1 + gradle.properties | 2 +- .../icebreaker/config/FafProperties.kt | 6 +++ .../service/xirsys/XirsysApiAdapter.kt | 49 +++++++++++++------ .../service/xirsys/XirsysProperties.kt | 2 + .../service/xirsys/XirsysSessionHandler.kt | 41 +++++++--------- .../service/xirsys/geolocation/GeoLocation.kt | 24 +++++++++ .../geolocation/RegionSelectorService.kt | 43 ++++++++++++++++ .../xirsys/geolocation/XirsysRegion.kt | 30 ++++++++++++ src/main/resources/application.yaml | 8 +-- 12 files changed, 179 insertions(+), 43 deletions(-) create mode 100644 .editorconfig create mode 100644 src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/GeoLocation.kt create mode 100644 src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/RegionSelectorService.kt create mode 100644 src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/XirsysRegion.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..85e6078 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +insert_final_newline = true + +[{*.kt,*.kts}] +ktlint_code_style = intellij_idea + +# Disable wildcard imports entirely +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = unset diff --git a/README.md b/README.md index b8e1c78..389cee1 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ The FAF ICE server broker that returns access to TURN and STUN servers both static (self-hosted coturn) and dynamically (Xirsys). -This project uses Quarkus, the Supersonic Subatomic Java Framework. +The available environment variables for configuration can be found in `src/main/resources/application.yaml`. Variables are declared like `${ENV_VARIABLE_NAME:fallback-value}`. -If you want to learn more about Quarkus, please visit its website: https://quarkus.io/ . +This application makes use of the [GeoLite2](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data) database to search for the closest Xirsys server according to the users ip address. The file must be present on startup or all requests will fall back to Frankfurt, Germany. ## Running the application in dev mode diff --git a/build.gradle.kts b/build.gradle.kts index 60cd520..e5b3184 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation("io.quarkus:quarkus-resteasy-reactive") implementation("io.quarkus:quarkus-flyway") implementation("org.flywaydb:flyway-mysql") + implementation("com.maxmind.geoip2:geoip2:4.1.0") testImplementation("io.quarkus:quarkus-junit5") testImplementation("io.rest-assured:rest-assured") } diff --git a/gradle.properties b/gradle.properties index ce4ad99..b35edc4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,4 @@ quarkusPluginId=io.quarkus quarkusPluginVersion=3.12.0 quarkusPlatformGroupId=io.quarkus.platform quarkusPlatformArtifactId=quarkus-bom -quarkusPlatformVersion=3.12.0 \ No newline at end of file +quarkusPlatformVersion=3.12.0 diff --git a/src/main/kotlin/com/faforever/icebreaker/config/FafProperties.kt b/src/main/kotlin/com/faforever/icebreaker/config/FafProperties.kt index 6aedaad..b240b84 100644 --- a/src/main/kotlin/com/faforever/icebreaker/config/FafProperties.kt +++ b/src/main/kotlin/com/faforever/icebreaker/config/FafProperties.kt @@ -11,6 +11,12 @@ interface FafProperties { */ fun environment(): String + /** + * Define the header, where to pick the real ip address from. For regular reverse proxies such as nginx or Traefik, + * this is X-Real-Ip. However, in certain scenarios such as Cloudflare proxy different headers might be required. + */ + fun realIpHeader(): String + fun tokenLifetimeSeconds(): Long fun maxSessionLifeTimeHours(): Long diff --git a/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysApiAdapter.kt b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysApiAdapter.kt index 83b3f04..a21ed4e 100644 --- a/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysApiAdapter.kt +++ b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysApiAdapter.kt @@ -1,14 +1,18 @@ package com.faforever.icebreaker.service.xirsys import com.faforever.icebreaker.config.FafProperties +import com.faforever.icebreaker.service.xirsys.geolocation.RegionSelectorService import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue +import io.vertx.core.http.HttpServerRequest import jakarta.inject.Singleton +import jakarta.ws.rs.core.Context import org.eclipse.microprofile.faulttolerance.Retry import org.eclipse.microprofile.rest.client.RestClientBuilder import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.IOException +import java.net.InetAddress import java.net.URI private val LOG: Logger = LoggerFactory.getLogger(XirsysApiAdapter::class.java) @@ -20,7 +24,9 @@ private val LOG: Logger = LoggerFactory.getLogger(XirsysApiAdapter::class.java) class XirsysApiAdapter( private val fafProperties: FafProperties, private val xirsysProperties: XirsysProperties, + private val regionSelectorService: RegionSelectorService, private val objectMapper: ObjectMapper, + @Context private val httpRequest: HttpServerRequest, ) { private val xirsysApiClient: XirsysApiClient = RestClientBuilder @@ -34,13 +40,12 @@ class XirsysApiAdapter( ).build(XirsysApiClient::class.java) @Retry - fun listChannel(): List = - parseAndUnwrap { - xirsysApiClient.listChannel( - namespace = xirsysProperties.channelNamespace(), - environment = fafProperties.environment(), - ) - } + fun listChannel(): List = parseAndUnwrap { + xirsysApiClient.listChannel( + namespace = xirsysProperties.channelNamespace(), + environment = fafProperties.environment(), + ) + } @Retry fun createChannel(channelName: String) { @@ -79,13 +84,26 @@ class XirsysApiAdapter( fun requestIceServers( channelName: String, turnRequest: TurnRequest = TurnRequest(), - ): TurnResponse = - parseAndUnwrap { - xirsysApiClient.requestIceServers( - namespace = xirsysProperties.channelNamespace(), - environment = fafProperties.environment(), - channelName = channelName, - turnRequest = turnRequest, + ): TurnResponse = parseAndUnwrap { + xirsysApiClient.requestIceServers( + namespace = xirsysProperties.channelNamespace(), + environment = fafProperties.environment(), + channelName = channelName, + turnRequest = turnRequest, + ) + }.withUserLocationServers() + + private fun TurnResponse.withUserLocationServers(): TurnResponse = + regionSelectorService.getClosestRegion(httpRequest.getIp()).let { region -> + copy( + iceServers = iceServers.copy( + urls = iceServers.urls.map { + it.replace( + regex = Regex("(.+:)([\\w-]+.xirsys.com)(.*)"), + replacement = "$1${region.domain}$3", + ) + }, + ), ) } @@ -107,4 +125,7 @@ class XirsysApiAdapter( throw XirsysUnspecifiedApiException(errorResponse = response, cause = e) } } + + private fun HttpServerRequest.getIp() = + InetAddress.getByName(getHeader(fafProperties.realIpHeader()) ?: remoteAddress().host()) } diff --git a/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysProperties.kt b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysProperties.kt index b8b8e93..96ad00a 100644 --- a/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysProperties.kt +++ b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysProperties.kt @@ -15,4 +15,6 @@ interface XirsysProperties { fun secret(): String fun channelNamespace(): String + + fun geoIpPath(): String } diff --git a/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysSessionHandler.kt b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysSessionHandler.kt index 0b34734..8b38058 100644 --- a/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysSessionHandler.kt +++ b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/XirsysSessionHandler.kt @@ -34,27 +34,22 @@ class XirsysSessionHandler( override fun getIceServers() = listOf(Server(id = SERVER_NAME, region = "Global")) - override fun getIceServersSession(sessionId: String): List = - xirsysApiAdapter - .requestIceServers( - channelName = sessionId, - turnRequest = TurnRequest(expire = fafProperties.tokenLifetimeSeconds()), - ).iceServers - .let { - listOf( - Session.Server( - id = SERVER_NAME, - username = it.username, - credential = it.credential, - urls = - it.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") }, - ), - ) - } + override fun getIceServersSession(sessionId: String): List = xirsysApiAdapter.requestIceServers( + channelName = sessionId, + turnRequest = TurnRequest(expire = fafProperties.tokenLifetimeSeconds()), + ).iceServers.let { + listOf( + Session.Server( + id = SERVER_NAME, + username = it.username, + credential = it.credential, + urls = it.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/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/GeoLocation.kt b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/GeoLocation.kt new file mode 100644 index 0000000..a0a6631 --- /dev/null +++ b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/GeoLocation.kt @@ -0,0 +1,24 @@ +package com.faforever.icebreaker.service.xirsys.geolocation + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +internal interface GeoLocation { + val latitude: Double + val longitude: Double + + // Haversine formula to calculate distance between two points in kilometers + fun distanceTo(other: GeoLocation): Double { + val r = 6371 // Radius of the earth in kilometers + val latDistance = Math.toRadians(other.latitude - this.latitude) + val lonDistance = Math.toRadians(other.longitude - this.longitude) + val a = + sin(latDistance / 2) * sin(latDistance / 2) + + cos(Math.toRadians(this.latitude)) * cos(Math.toRadians(other.latitude)) * + sin(lonDistance / 2) * sin(lonDistance / 2) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return r * c // Distance in kilometers + } +} diff --git a/src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/RegionSelectorService.kt b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/RegionSelectorService.kt new file mode 100644 index 0000000..0dd9531 --- /dev/null +++ b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/RegionSelectorService.kt @@ -0,0 +1,43 @@ +package com.faforever.icebreaker.service.xirsys.geolocation + +import com.faforever.icebreaker.service.xirsys.XirsysProperties +import com.maxmind.geoip2.DatabaseReader +import com.maxmind.geoip2.model.CityResponse +import jakarta.inject.Singleton +import org.slf4j.LoggerFactory +import java.net.InetAddress +import java.nio.file.Files +import java.nio.file.Paths + +private val LOG = LoggerFactory.getLogger(RegionSelectorService::class.java) + +@Singleton +class RegionSelectorService( + xirsysProperties: XirsysProperties, +) { + private val geoIpDatabasePath = Paths.get(xirsysProperties.geoIpPath()) + + private val geoLocationReader: DatabaseReader? by lazy { + if (!Files.exists(geoIpDatabasePath)) { + LOG.warn("No geo database found at $geoIpDatabasePath! Will use fallback region ${XirsysRegion.fallbackRegion.name}") + null + } else { + DatabaseReader.Builder(geoIpDatabasePath.toFile()).build() + } + } + + private data class UserLocation( + override val latitude: Double, + override val longitude: Double, + ) : GeoLocation + + fun getClosestRegion(ipAddress: InetAddress): XirsysRegion = + geoLocationReader + ?.tryCity(ipAddress) + ?.map(CityResponse::getLocation) + ?.map { getClosestRegion(UserLocation(latitude = it.latitude, longitude = it.longitude)) } + ?.orElse(null) + ?: XirsysRegion.fallbackRegion + + private fun getClosestRegion(userLocation: UserLocation): XirsysRegion = XirsysRegion.allRegions.minBy { it.distanceTo(userLocation) } +} diff --git a/src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/XirsysRegion.kt b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/XirsysRegion.kt new file mode 100644 index 0000000..cb0a458 --- /dev/null +++ b/src/main/kotlin/com/faforever/icebreaker/service/xirsys/geolocation/XirsysRegion.kt @@ -0,0 +1,30 @@ +package com.faforever.icebreaker.service.xirsys.geolocation + +data class XirsysRegion( + val domain: String, + val name: String, + val city: String, + override val latitude: Double, + override val longitude: Double, +) : GeoLocation { + companion object { + // listed on https://docs.xirsys.com/?pg=api-intro (manually enriched with geo coordinates) + val allRegions = + listOf( + XirsysRegion("ws.xirsys.com", "US West", "San Jose, CA", 37.33939000, -121.89496000), + XirsysRegion("us.xirsys.com", "US East", "Washington, DC", 38.89511000, -77.03637000), + XirsysRegion("es.xirsys.com", "Europe", "Amsterdam", 52.37403000, 4.88969000), + XirsysRegion("bs.xirsys.com", "India", "Bangalore", 12.97194000, 77.59369000), + XirsysRegion("tk.xirsys.com", "Japan", "Tokyo", 35.67619190, 139.65031060), + XirsysRegion("hk.xirsys.com", "China", "Hong Kong", 22.396428, 114.109497), + XirsysRegion("ss.xirsys.com", "Asia", "Singapore", 1.352083, 103.819836), + XirsysRegion("ms.xirsys.com", "Australia", "Sydney", -33.86785, 151.20732), + XirsysRegion("sp.xirsys.com", "Brazil", "São Paulo", -23.5475, -46.63611), + XirsysRegion("to.xirsys.com", "Canada East", "Toronto", 43.70011, -79.4163), + XirsysRegion("jb.xirsys.com", "South Africa", "Johannesburg", -26.20227, 28.04363), + XirsysRegion("fr.xirsys.com", "Germany", "Frankfurt", 50.11552, 8.68417), + ) + + val fallbackRegion = allRegions.last() + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2a947b5..551b5c3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -14,12 +14,11 @@ quarkus: flyway: migrate-at-start: true oidc: - auth-server-url: ${HYDRA_URL:http://localhost:4444} + auth-server-url: ${HYDRA_URL:https://hydra.faforever.com} # A tenant for our self-signed JWTs # (also requires the CustomTenantResolver) self-tenant: # There is no .well-known/openid-configuration - auth-server-url: ${SELF_URL:https://ice.faforever.com} discovery-enabled: false token: # Hard coded JWT settings, as there is no JWKS @@ -40,6 +39,8 @@ quarkus: faf: self-url: ${SELF_URL:https://ice.faforever.com} environment: ${ENVIRONMENT:dev} + real-ip-header: ${REAL_IP_HEADER:X-Real-Ip} + token-lifetime-seconds: 86400 # 24h because of lobbies/games/reconnects can happen for a long time max-session-life-time-hours: 24 xirsys: @@ -49,6 +50,7 @@ xirsys: secret: ${XIRSYS_SECRET} channel-namespace: "faf" turn-enabled: ${XIRSYS_TURN_ENABLED:true} + geo-ip-path: ${GEO_IP_DATABASE_PATH:"/geoip/GeoLite2-City.mmdb"} smallrye: jwt: sign: @@ -108,4 +110,4 @@ smallrye: "com.faforever": level: DEBUG "io.quarkus": - level: DEBUG \ No newline at end of file + level: INFO