Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ android {
applicationId = "com.vauth.foxyvpn"
minSdk = 26
targetSdk = 35
versionCode = 48
versionName = "2.0.2"
versionCode = 49
versionName = "2.0.3"
}

splits {
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import com.vauth.foxyvpn.data.FoxyRemoteConfig
import com.vauth.foxyvpn.data.FxaAuthRepository
import com.vauth.foxyvpn.data.GuardianClient
import com.vauth.foxyvpn.data.ProxyStateStore
import com.vauth.foxyvpn.data.RelayerConfig
import com.vauth.foxyvpn.data.ServerListClient
import com.vauth.foxyvpn.data.SettingsStore
import com.vauth.foxyvpn.data.TokenStore
import com.vauth.foxyvpn.data.UpstreamProxyConfig
import com.vauth.foxyvpn.update.AppUpdater
import com.vauth.foxyvpn.vpn.FoxyVpnService
import com.vauth.foxyvpn.widget.QuickConnectWidgetProvider
Expand Down Expand Up @@ -55,6 +57,26 @@ class FoxyVpnApp : Application(), AppContainer {
ControlPlaneHttp.bootstrapDnsConfigProvider = {
settingsStore.bootstrapDnsProvider to settingsStore.effectiveBootstrapDohUrl
}
ControlPlaneHttp.upstreamProxyProvider = {
if (settingsStore.upstreamProxyEnabled && settingsStore.upstreamProxyHost.isNotBlank()) {
UpstreamProxyConfig(
type = settingsStore.upstreamProxyType,
host = settingsStore.upstreamProxyHost,
port = settingsStore.upstreamProxyPort,
username = settingsStore.upstreamProxyUsername,
password = settingsStore.upstreamProxyPassword,
)
} else null
}
ControlPlaneHttp.relayerConfigProvider = {
RelayerConfig(
provider = settingsStore.relayerProvider,
customUrl = settingsStore.customRelayerUrl,
relayedEndpoints = settingsStore.relayedEndpoints,
availableRelayers = FoxyRemoteConfig.current.relayers,
failoverCodes = FoxyRemoteConfig.current.failoverCodes,
)
}
FoxyRemoteConfig.init(this)
FoxyRemoteConfig.triggerRefresh()
guardianClient = GuardianClient()
Expand Down
77 changes: 77 additions & 0 deletions app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,39 @@ package com.vauth.foxyvpn.data

import android.net.Network
import android.webkit.CookieManager
import java.io.IOException
import java.net.Authenticator as JavaAuthenticator
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.PasswordAuthentication
import java.net.Proxy
import java.net.ProxySelector
import java.net.Socket
import java.net.SocketAddress
import java.net.URI
import java.util.concurrent.TimeUnit
import javax.net.SocketFactory
import okhttp3.Authenticator as OkHttpAuthenticator
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.Credentials
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import okhttp3.dnsoverhttps.DnsOverHttps

data class UpstreamProxyConfig(
val type: SettingsStore.UpstreamProxyType,
val host: String,
val port: Int,
val username: String = "",
val password: String = "",
)

fun Request.Builder.applyFirefoxHeaders(): Request.Builder = FirefoxAccountHeaders.apply(this)

class SimpleCookieJar : CookieJar {
Expand Down Expand Up @@ -129,6 +148,58 @@ object ControlPlaneHttp {

@Volatile var bootstrapDnsConfigProvider: (() -> Pair<SettingsStore.DnsProvider, String>)? = null

@Volatile var upstreamProxyProvider: (() -> UpstreamProxyConfig?)? = null

@Volatile var relayerConfigProvider: (() -> RelayerConfig)? = null

private val relayerInterceptor = RelayerInterceptor { relayerConfigProvider?.invoke() }

init {
JavaAuthenticator.setDefault(
object : JavaAuthenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
val config = upstreamProxyProvider?.invoke() ?: return null
if (requestorType == RequestorType.PROXY && config.username.isNotBlank()) {
return PasswordAuthentication(config.username, config.password.toCharArray())
}
return null
}
}
)
}

private val dynamicProxySelector =
object : ProxySelector() {
override fun select(uri: URI?): List<Proxy> {
val config = upstreamProxyProvider?.invoke()
if (config != null && config.host.isNotBlank() && config.port in 1..65535) {
val proxyType =
if (config.type == SettingsStore.UpstreamProxyType.HTTP) {
Proxy.Type.HTTP
} else {
Proxy.Type.SOCKS
}
return listOf(Proxy(proxyType, InetSocketAddress.createUnresolved(config.host, config.port)))
}
return listOf(Proxy.NO_PROXY)
}

override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {}
}

private val dynamicProxyAuthenticator =
object : OkHttpAuthenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val config = upstreamProxyProvider?.invoke() ?: return null
if (config.username.isBlank()) return null
if (response.request.header("Proxy-Authorization") != null) return null
val credential = Credentials.basic(config.username, config.password)
return response.request.newBuilder()
.header("Proxy-Authorization", credential)
.build()
}
}

internal fun underlyingNetwork(): Network? {
val provider = networkProvider ?: return null
return provider() ?: throw java.io.IOException("No underlying network is available")
Expand All @@ -137,6 +208,9 @@ object ControlPlaneHttp {
private val dohBootstrapClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
.addInterceptor(relayerInterceptor)
.proxySelector(dynamicProxySelector)
.proxyAuthenticator(dynamicProxyAuthenticator)
.socketFactory(ProtectingSocketFactory())
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
Expand Down Expand Up @@ -199,7 +273,10 @@ object ControlPlaneHttp {
val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
.addInterceptor(relayerInterceptor)
.cookieJar(cookieJar)
.proxySelector(dynamicProxySelector)
.proxyAuthenticator(dynamicProxyAuthenticator)
.socketFactory(ProtectingSocketFactory())
.dns(
object : okhttp3.Dns {
Expand Down
48 changes: 34 additions & 14 deletions app/src/main/java/com/vauth/foxyvpn/data/FastlyChallengeSolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
val solverMutex = Mutex()
}

private fun allowedHosts(): Set<String> {
val hosts = mutableSetOf(
"vpn.mozilla.org",
"api.accounts.firefox.com",
"oauth.accounts.firefox.com",
"accounts.firefox.com",
)
val endpoints = FoxyRemoteConfig.current.endpoints
val urls = listOf(
endpoints.controlPlane.login,
endpoints.controlPlane.recoveryStatus,
endpoints.controlPlane.sessionDestroy,
endpoints.controlPlane.oauthToken,
endpoints.controlPlane.oauthFallback,
endpoints.controlPlane.authorization,
endpoints.guardian.token,
endpoints.guardian.status,
endpoints.guardian.activate,
endpoints.serverList,
endpoints.productDetails,
)
for (url in urls) {
runCatching { url.toHttpUrl().host }.getOrNull()?.let { hosts.add(it) }
}
return hosts
}

suspend fun solveAndInstall(
challengedBody: String? = null,
challengedOrigin: String? = null,
Expand All @@ -47,18 +74,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
withContext(Dispatchers.IO) {
val origin =
(challengedOrigin ?: "https://api.accounts.firefox.com").toHttpUrl()
val trustedHosts = allowedHosts()
require(
origin.scheme == "https" &&
origin.port == 443 &&
origin.username.isEmpty() &&
origin.password.isEmpty() &&
origin.host in
setOf(
"vpn.mozilla.org",
"api.accounts.firefox.com",
"oauth.accounts.firefox.com",
"accounts.firefox.com",
)
origin.host in trustedHosts
) {
"Untrusted challenge origin"
}
Expand All @@ -69,9 +91,13 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
solveChallengePage(challengedBody, base, userAgent, solver, solverJar)
return@withContext
}
val defaultBase =
runCatching { FoxyRemoteConfig.current.endpoints.controlPlane.oauthToken.toHttpUrl() }
.getOrNull()?.let { "https://${it.host}" } ?: "https://api.accounts.firefox.com"
val bases =
listOf(
base,
defaultBase,
"https://api.accounts.firefox.com",
"https://accounts.firefox.com",
).distinct()
Expand Down Expand Up @@ -291,13 +317,7 @@ class FastlyChallengeSolver(private val cookieJar: SimpleCookieJar) {
if (cookies.isEmpty())
throw FastlyChallengeError("challenge completed but no cookies were issued")

val targetHosts =
listOf(
"api.accounts.firefox.com",
"accounts.firefox.com",
"oauth.accounts.firefox.com",
"vpn.mozilla.org",
)
val targetHosts = allowedHosts()

for (cookie in cookies) {
val hostClean = cookie.domain.trimStart('.')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import okhttp3.Request
import org.json.JSONObject

internal object FirefoxAccountHeaders {
private const val PRODUCT_DETAILS_URL =
"https://product-details.mozilla.org/1.0/firefox_versions.json"
private val mutex = Mutex()
@Volatile private var major = "159"
private var nextRefreshNanos: Long? = null
Expand Down Expand Up @@ -45,7 +43,7 @@ internal object FirefoxAccountHeaders {
try {
withTimeoutOrNull(5_000L) {
val request =
apply(Request.Builder().url(PRODUCT_DETAILS_URL)).get().build()
apply(Request.Builder().url(FoxyRemoteConfig.current.endpoints.productDetails)).get().build()
client.newCall(request).awaitResponse().use { response ->
if (!response.isSuccessful) return@withTimeoutOrNull false
val text =
Expand Down
Loading
Loading