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
2 changes: 1 addition & 1 deletion .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: foxyvpn-debug-apk
path: app/build/outputs/apk/debug/*.apk
path: app/build/outputs/apk/debug/*arm64-v8a*.apk
if-no-files-found: error
- name: Upload diagnostics
if: always()
Expand Down
5 changes: 3 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 = 47
versionName = "2.0.1"
versionCode = 48
versionName = "2.0.2"
}

splits {
Expand Down Expand Up @@ -112,6 +112,7 @@ dependencies {
implementation("androidx.core:core-ktx:1.15.0")

implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")

implementation("androidx.security:security-crypto:1.1.0")
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.content.res.Configuration
import com.vauth.foxyvpn.data.ControlPlaneHttp
import com.vauth.foxyvpn.data.CrashReporter
import com.vauth.foxyvpn.data.FoxyRemoteConfig
import com.vauth.foxyvpn.data.FxaAuthRepository
import com.vauth.foxyvpn.data.GuardianClient
import com.vauth.foxyvpn.data.ProxyStateStore
Expand Down Expand Up @@ -54,6 +55,8 @@ class FoxyVpnApp : Application(), AppContainer {
ControlPlaneHttp.bootstrapDnsConfigProvider = {
settingsStore.bootstrapDnsProvider to settingsStore.effectiveBootstrapDohUrl
}
FoxyRemoteConfig.init(this)
FoxyRemoteConfig.triggerRefresh()
guardianClient = GuardianClient()
serverListClient = ServerListClient(this)
authRepository = FxaAuthRepository(tokenStore)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/vauth/foxyvpn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
import com.vauth.foxyvpn.data.FoxyRemoteConfig
import com.vauth.foxyvpn.ui.navigation.FoxyNavGraph
import com.vauth.foxyvpn.ui.theme.FoxyVpnTheme
import com.vauth.foxyvpn.ui.theme.rememberThemeController
Expand Down Expand Up @@ -68,6 +69,7 @@ class MainActivity : ComponentActivity() {

override fun onStart() {
super.onStart()
FoxyRemoteConfig.triggerRefresh()
(application as FoxyVpnApp).appUpdater.check(automatic = true)
}

Expand Down
242 changes: 54 additions & 188 deletions app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,19 @@ package com.vauth.foxyvpn.data

import android.net.Network
import android.webkit.CookieManager
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.net.URI
import java.util.concurrent.TimeUnit
import javax.net.SocketFactory
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.dnsoverhttps.DnsOverHttps

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

Expand Down Expand Up @@ -53,75 +47,6 @@ class SimpleCookieJar : CookieJar {
cookies.clear()
}

@Synchronized
fun syncFromWebView(
urls: List<String> =
listOf(
"https://accounts.firefox.com",
"https://api.accounts.firefox.com",
"https://oauth.accounts.firefox.com",
"https://vpn.mozilla.org",
),
) {
val manager = runCatching { CookieManager.getInstance() }.getOrNull() ?: return
for (urlStr in urls) {
val httpUrl = urlStr.toHttpUrlOrNull() ?: continue
val cookieHeader = runCatching { manager.getCookie(urlStr) }.getOrNull() ?: continue
val parsedCookies = mutableListOf<Cookie>()
for (raw in cookieHeader.split(";")) {
val trimmed = raw.trim()
if (trimmed.isEmpty()) continue
val eqIdx = trimmed.indexOf('=')
if (eqIdx <= 0) continue
val name = trimmed.substring(0, eqIdx).trim()
val value = trimmed.substring(eqIdx + 1).trim()
if (name.isEmpty() || value.isEmpty()) continue

val hostCookie =
runCatching {
Cookie.Builder()
.name(name)
.value(value)
.domain(httpUrl.host)
.path("/")
.apply { if (httpUrl.isHttps) secure() }
.build()
}.getOrNull()
if (hostCookie != null) parsedCookies.add(hostCookie)

if (httpUrl.host.endsWith("firefox.com") && httpUrl.host != "firefox.com") {
val domainCookie =
runCatching {
Cookie.Builder()
.name(name)
.value(value)
.domain("firefox.com")
.path("/")
.apply { if (httpUrl.isHttps) secure() }
.build()
}.getOrNull()
if (domainCookie != null) parsedCookies.add(domainCookie)
for (target in listOf("api.accounts.firefox.com", "oauth.accounts.firefox.com", "vpn.mozilla.org")) {
val targetCookie =
runCatching {
Cookie.Builder()
.name(name)
.value(value)
.domain(target)
.path("/")
.apply { if (httpUrl.isHttps) secure() }
.build()
}.getOrNull()
if (targetCookie != null) parsedCookies.add(targetCookie)
}
}
}
if (parsedCookies.isNotEmpty()) {
saveFromResponse(httpUrl, parsedCookies)
}
}
}

@Synchronized
fun syncToWebView(cookies: List<Cookie>) {
val manager = runCatching { CookieManager.getInstance() }.getOrNull() ?: return
Expand Down Expand Up @@ -209,130 +134,71 @@ object ControlPlaneHttp {
return provider() ?: throw java.io.IOException("No underlying network is available")
}

private val dohClient: OkHttpClient by lazy {
private val dohBootstrapClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
.socketFactory(ProtectingSocketFactory())
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.callTimeout(5, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.callTimeout(10, TimeUnit.SECONDS)
.build()
}

private fun queryDoh(dohUrl: String, hostname: String): List<InetAddress> {
val dohHost = runCatching { URI(dohUrl).host?.removeSurrounding("[", "]") }.getOrNull()
if (dohHost != null && dohHost.equals(hostname, ignoreCase = true)) {
return emptyList()
}

var httpReachable = false
fun sendDohQuery(type: Int): List<InetAddress> {
val queryBytes = buildDnsQuery(hostname, type)
val request =
Request.Builder()
.url(dohUrl)
.post(queryBytes.toRequestBody("application/dns-message".toMediaType()))
.header("Accept", "application/dns-message")
.build()

return runCatching {
dohClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return emptyList()
httpReachable = true
val responseBody = response.body?.bytes() ?: return emptyList()
parseDnsResponse(responseBody, hostname)
}
}.getOrDefault(emptyList())
}

val ipv4Results = sendDohQuery(type = 1)
if (ipv4Results.isNotEmpty() || !httpReachable) return ipv4Results
return sendDohQuery(type = 28)
}

private fun buildDnsQuery(hostname: String, type: Int): ByteArray {
val baos = ByteArrayOutputStream()
val dos = DataOutputStream(baos)
dos.writeShort(0x1a2b)
dos.writeShort(0x0100)
dos.writeShort(1)
dos.writeShort(0)
dos.writeShort(0)
dos.writeShort(0)
@Volatile private var cachedDohUrl: String? = null
@Volatile private var cachedDnsOverHttps: DnsOverHttps? = null
private val dohLock = Any()

for (label in hostname.split('.')) {
if (label.isEmpty()) continue
val bytes = label.toByteArray(Charsets.US_ASCII)
dos.writeByte(bytes.size)
dos.write(bytes)
private fun getOrCreateDoh(dohUrl: String): DnsOverHttps? {
if (cachedDohUrl == dohUrl) {
val cached = cachedDnsOverHttps
if (cached != null) return cached
}
dos.writeByte(0)
dos.writeShort(type)
dos.writeShort(1)
return baos.toByteArray()
}

private fun parseDnsResponse(data: ByteArray, hostname: String): List<InetAddress> {
if (data.size < 12) return emptyList()
val dis = DataInputStream(ByteArrayInputStream(data))
dis.readShort()
val flags = dis.readUnsignedShort()
val isResponse = (flags and 0x8000) != 0
val rcode = flags and 0x000F
if (!isResponse || rcode != 0) return emptyList()
val qdCount = dis.readUnsignedShort()
val anCount = dis.readUnsignedShort()
dis.readUnsignedShort()
dis.readUnsignedShort()

for (i in 0 until qdCount) {
skipDomainName(dis)
dis.readShort()
dis.readShort()
}

val results = mutableListOf<InetAddress>()
for (i in 0 until anCount) {
skipDomainName(dis)
val type = dis.readUnsignedShort()
val clazz = dis.readUnsignedShort()
dis.readInt()
val rdLength = dis.readUnsignedShort()
val rdata = ByteArray(rdLength)
dis.readFully(rdata)

if (clazz == 1) {
if (type == 1 && rdLength == 4) {
runCatching { InetAddress.getByAddress(hostname, rdata) }
.getOrNull()
?.let { results.add(it) }
} else if (type == 28 && rdLength == 16) {
runCatching { InetAddress.getByAddress(hostname, rdata) }
.getOrNull()
?.let { results.add(it) }
}
return synchronized(dohLock) {
if (cachedDohUrl == dohUrl) {
val cached = cachedDnsOverHttps
if (cached != null) return@synchronized cached
}
}
return results
}

private fun skipDomainName(dis: DataInputStream) {
var steps = 0
while (steps++ < 128) {
val len = dis.readUnsignedByte()
if (len == 0) break
if ((len and 0xC0) == 0xC0) {
dis.readByte()
break
val httpUrl = dohUrl.toHttpUrlOrNull() ?: return@synchronized null
val host = httpUrl.host.removeSurrounding("[", "]")
val bootstrapHosts = mutableListOf<InetAddress>()
if (SettingsStore.isValidIpAddress(host)) {
runCatching { InetAddress.getByName(host) }.getOrNull()?.let { bootstrapHosts.add(it) }
} else {
dis.skipBytes(len)
when {
host.equals("dns.google", ignoreCase = true) -> {
bootstrapHosts.add(InetAddress.getByName("8.8.8.8"))
bootstrapHosts.add(InetAddress.getByName("8.8.4.4"))
}
host.equals("cloudflare-dns.com", ignoreCase = true) ||
host.equals("one.one.one.one", ignoreCase = true) -> {
bootstrapHosts.add(InetAddress.getByName("1.1.1.1"))
bootstrapHosts.add(InetAddress.getByName("1.0.0.1"))
}
host.equals("dns.quad9.net", ignoreCase = true) -> {
bootstrapHosts.add(InetAddress.getByName("9.9.9.9"))
bootstrapHosts.add(InetAddress.getByName("149.112.112.112"))
}
}
}
val builder =
DnsOverHttps.Builder()
.client(dohBootstrapClient)
.url(httpUrl)
.post(true)
.includeIPv6(true)
if (bootstrapHosts.isNotEmpty()) {
builder.bootstrapDnsHosts(bootstrapHosts)
}
val doh = builder.build()
cachedDohUrl = dohUrl
cachedDnsOverHttps = doh
doh
}
}

val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
.cookieJar(cookieJar)
.socketFactory(ProtectingSocketFactory())
.dns(
Expand All @@ -345,9 +211,9 @@ object ControlPlaneHttp {
val provider = config?.first ?: SettingsStore.DnsProvider.DIRECT_SYSTEM
val dohUrl = config?.second.orEmpty()
if (provider != SettingsStore.DnsProvider.DIRECT_SYSTEM && dohUrl.isNotBlank()) {
val dohResults = runCatching { queryDoh(dohUrl, hostname) }.getOrNull()
if (!dohResults.isNullOrEmpty()) {
return dohResults
val doh = getOrCreateDoh(dohUrl)
if (doh != null) {
return doh.lookup(hostname)
}
}
val network = runCatching { underlyingNetwork() }.getOrNull()
Expand Down
Loading
Loading