Skip to content

Commit

Permalink
Pick closest Xirsys server based on users geo location
Browse files Browse the repository at this point in the history
  • Loading branch information
Brutus5000 committed Jul 8, 2024
1 parent d0bd627 commit b8cacae
Show file tree
Hide file tree
Showing 12 changed files with 179 additions and 43 deletions.
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ quarkusPluginId=io.quarkus
quarkusPluginVersion=3.12.0
quarkusPlatformGroupId=io.quarkus.platform
quarkusPlatformArtifactId=quarkus-bom
quarkusPlatformVersion=3.12.0
quarkusPlatformVersion=3.12.0
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand All @@ -34,13 +40,12 @@ class XirsysApiAdapter(
).build(XirsysApiClient::class.java)

@Retry
fun listChannel(): List<String> =
parseAndUnwrap {
xirsysApiClient.listChannel(
namespace = xirsysProperties.channelNamespace(),
environment = fafProperties.environment(),
)
}
fun listChannel(): List<String> = parseAndUnwrap {
xirsysApiClient.listChannel(
namespace = xirsysProperties.channelNamespace(),
environment = fafProperties.environment(),
)
}

@Retry
fun createChannel(channelName: String) {
Expand Down Expand Up @@ -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<TurnResponse> {
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",
)
},
),
)
}

Expand All @@ -107,4 +125,7 @@ class XirsysApiAdapter(
throw XirsysUnspecifiedApiException(errorResponse = response, cause = e)
}
}

private fun HttpServerRequest.getIp() =
InetAddress.getByName(getHeader(fafProperties.realIpHeader()) ?: remoteAddress().host())
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ interface XirsysProperties {
fun secret(): String

fun channelNamespace(): String

fun geoIpPath(): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,22 @@ class XirsysSessionHandler(

override fun getIceServers() = listOf(Server(id = SERVER_NAME, region = "Global"))

override fun getIceServersSession(sessionId: String): List<Session.Server> =
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<Session.Server> = 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") },
),
)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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) }
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
8 changes: 5 additions & 3 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -108,4 +110,4 @@ smallrye:
"com.faforever":
level: DEBUG
"io.quarkus":
level: DEBUG
level: INFO

0 comments on commit b8cacae

Please sign in to comment.