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 .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ concurrency:
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

env:
LIBBOX_URL: https://github.com/Vauth/sing-box/releases/download/v1.15.0-foxy-masque.2/libbox.aar
LIBBOX_SHA256: f1b59f4fab207b4c19f8ea3d145bc4dd0e834a530081d0dc045f4b795d6eac0b
LIBBOX_URL: https://github.com/Vauth/sing-box/releases/download/v1.0.0-masque/libbox.aar
LIBBOX_SHA256: a6a28c7f3cb53f4251d1578f4772f79831395503fdbf91920c6c1e33cc80281c

jobs:
build:
Expand Down
31 changes: 17 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
![Sing-Box](https://img.shields.io/badge/Sing--Box-212121?style=for-the-badge&logo=go&logoColor=white)
![License](https://img.shields.io/badge/License_MIT-212121?style=for-the-badge)

*FoxyVPN is a modern, privacy-focused Android client for Mozilla's Firefox VPN service. Powered by a customized sing-box core, it connects directly to Mozilla's global Fastly edge network using high-performance MASQUE protocol with HTTP/2 and HTTP/3 support.*
*FoxyVPN is a modern, privacy-focused Android client for Mozilla's Firefox VPN service. Powered by a customized sing-box core, it connects directly to Mozilla's global Fastly edge network using high-performance MASQUE protocol with HTTP/1.1, HTTP/2, and HTTP/3 support.*

</div>

Expand All @@ -22,28 +22,31 @@

### Core & Privacy
- **Firefox Account Integration**
- **System-Wide Tunneling**
- **Split Tunneling**
- **DNS-over-HTTPS (DoH)**
- **System-Wide VPN Tunneling**
- **Split Tunneling (Per-App Selection)**
- **IP Protocol Modes (Dual-Stack, Prefer IPv4/IPv6, IPv4/IPv6-Only)**
- **Local HTTP / SOCKS5 Proxy Server & LAN Sharing**
- **Custom & Bootstrap DNS-over-HTTPS (DoH)**
- **Local Network Bypass**
- **Proxy-Only Mode**

### Performance & Censorship Resistance
- **TLS Fragmentation**
- **MASQUE over HTTP/1.1, HTTP/2, and HTTP/3 (QUIC)**
- **TLS Fragmentation (Record & Packet)**
- **uTLS Fingerprint Emulation**
- **Upstream Proxy Chaining**
- **Custom Edge Override**
- **TCP Fast Open (TFO)**
- **Multiplexing Controls**
- **Multiplexing & Concurrency Controls**
- **Custom Tunnel MTU**
- **Automatic Connection Recovery**

### User Experience & System Integration
- **Real-Time Diagnostics**
- **Real-Time Traffic & Latency Diagnostics**
- **Public & Exit IP Detection**
- **Global Locations**
- **Quick Settings Tile & Widget**
- **In-App Auto-Updates**
- **Diagnostic Logging**
- **Global Server Locations**
- **Quick Settings Tile & Home Screen Widget**
- **Release Update Checks & Notifications**
- **Diagnostic Logging & Export**

<br>

Expand Down Expand Up @@ -86,8 +89,8 @@
2. Download and verify the pinned sing-box core (`libbox.aar`):
```bash
mkdir -p app/libs
curl -L -o app/libs/libbox.aar https://github.com/Vauth/sing-box/releases/download/v1.15.0-foxy-masque.2/libbox.aar
echo "f1b59f4fab207b4c19f8ea3d145bc4dd0e834a530081d0dc045f4b795d6eac0b app/libs/libbox.aar" | sha256sum --check --strict
curl -L -o app/libs/libbox.aar https://github.com/Vauth/sing-box/releases/download/v1.0.0-masque/libbox.aar
echo "a6a28c7f3cb53f4251d1578f4772f79831395503fdbf91920c6c1e33cc80281c app/libs/libbox.aar" | sha256sum --check --strict
```

3. Build the APKs:
Expand Down
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 = 46
versionName = "2.0.0"
versionCode = 47
versionName = "2.0.1"
}

splits {
Expand Down
1 change: 0 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
Expand Down
23 changes: 19 additions & 4 deletions app/src/main/java/com/vauth/foxyvpn/FoxyVpnApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import android.app.NotificationChannel
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.FxaAuthRepository
import com.vauth.foxyvpn.data.GuardianClient
Expand Down Expand Up @@ -50,20 +51,33 @@ class FoxyVpnApp : Application(), AppContainer {
tokenStore = TokenStore(this)
proxyStateStore = ProxyStateStore(this)
settingsStore = SettingsStore(this)
ControlPlaneHttp.bootstrapDnsConfigProvider = {
settingsStore.bootstrapDnsProvider to settingsStore.effectiveBootstrapDohUrl
}
guardianClient = GuardianClient()
serverListClient = ServerListClient(this)
authRepository = FxaAuthRepository(tokenStore)
createNotificationChannel()
createNotificationChannels()
}

private fun createNotificationChannel() {
val channel =
private fun createNotificationChannels() {
val manager = getSystemService(NotificationManager::class.java) ?: return
val vpnChannel =
NotificationChannel(
VPN_NOTIFICATION_CHANNEL_ID,
"VPN status",
NotificationManager.IMPORTANCE_LOW,
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val updateChannel =
NotificationChannel(
UPDATE_NOTIFICATION_CHANNEL_ID,
getString(R.string.update_notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = getString(R.string.update_notification_channel_desc)
}
manager.createNotificationChannel(vpnChannel)
manager.createNotificationChannel(updateChannel)
}

override fun onConfigurationChanged(newConfig: Configuration) {
Expand All @@ -73,5 +87,6 @@ class FoxyVpnApp : Application(), AppContainer {

companion object {
const val VPN_NOTIFICATION_CHANNEL_ID = "foxyvpn_status"
const val UPDATE_NOTIFICATION_CHANNEL_ID = "foxyvpn_updates"
}
}
2 changes: 0 additions & 2 deletions app/src/main/java/com/vauth/foxyvpn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import androidx.navigation.compose.rememberNavController
import com.vauth.foxyvpn.ui.navigation.FoxyNavGraph
import com.vauth.foxyvpn.ui.theme.FoxyVpnTheme
import com.vauth.foxyvpn.ui.theme.rememberThemeController
import com.vauth.foxyvpn.update.UpdatePrompt
import com.vauth.foxyvpn.vpn.FoxyVpnService

class MainActivity : ComponentActivity() {
Expand Down Expand Up @@ -80,7 +79,6 @@ class MainActivity : ComponentActivity() {
FoxyVpnTheme(themeMode = themeController.mode) {
Surface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
UpdatePrompt(app.appUpdater)
FoxyNavGraph(
navController = navController,
container = app,
Expand Down
1 change: 0 additions & 1 deletion app/src/main/java/com/vauth/foxyvpn/data/AppLinks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@ object AppLinks {
const val REPOSITORY = "Vauth/FoxyVPN"
const val SOURCE = "https://github.com/$REPOSITORY"
const val RELEASE_API = "https://api.github.com/repos/$REPOSITORY/releases/latest"
const val RELEASE_DOWNLOAD_PATH = "/$REPOSITORY/releases/download/"
}
144 changes: 143 additions & 1 deletion app/src/main/java/com/vauth/foxyvpn/data/ControlPlaneHttp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,25 @@ 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

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

Expand Down Expand Up @@ -148,7 +155,7 @@ private class ProtectingSocketFactory : SocketFactory() {
if (protector != null && !protector(socket)) {
throw java.io.IOException("VPN refused control-plane socket protection")
}
ControlPlaneHttp.underlyingNetwork()?.bindSocket(socket)
runCatching { ControlPlaneHttp.underlyingNetwork()?.bindSocket(socket) }
if (remoteAddress != null) socket.connect(remoteAddress)
return socket
} catch (error: Exception) {
Expand Down Expand Up @@ -195,11 +202,134 @@ object ControlPlaneHttp {

@Volatile var networkProvider: (() -> Network?)? = null

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

internal fun underlyingNetwork(): Network? {
val provider = networkProvider ?: return null
return provider() ?: throw java.io.IOException("No underlying network is available")
}

private val dohClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
.socketFactory(ProtectingSocketFactory())
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.callTimeout(5, 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)

for (label in hostname.split('.')) {
if (label.isEmpty()) continue
val bytes = label.toByteArray(Charsets.US_ASCII)
dos.writeByte(bytes.size)
dos.write(bytes)
}
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 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
} else {
dis.skipBytes(len)
}
}
}

val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.protocols(listOf(Protocol.HTTP_1_1))
Expand All @@ -208,6 +338,18 @@ object ControlPlaneHttp {
.dns(
object : okhttp3.Dns {
override fun lookup(hostname: String): List<InetAddress> {
if (SettingsStore.isValidIpAddress(hostname)) {
return listOf(InetAddress.getByName(hostname))
}
val config = bootstrapDnsConfigProvider?.invoke()
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 network = runCatching { underlyingNetwork() }.getOrNull()
val fromNetwork =
if (network != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class LocalProxyOptions(
val bindAddress: String = SettingsStore.DEFAULT_SOCKS_BIND_ADDRESS,
val port: Int,
val authentication: Boolean,
val username: String,
val password: String,
) {
override fun toString() = "LocalProxyOptions(port=$port, authentication=$authentication)"
override fun toString() =
"LocalProxyOptions(bindAddress=$bindAddress, port=$port, authentication=$authentication)"
}

class UpstreamProxyOptions(
Expand All @@ -24,6 +26,7 @@ class UpstreamProxyOptions(
}

data class ProxySummary(
val localBindAddress: String,
val localPort: Int,
val upstreamEnabled: Boolean,
val upstreamType: SettingsStore.UpstreamProxyType,
Expand All @@ -37,6 +40,7 @@ class ProxySettingsRepository(private val store: SettingsStore) {
withContext(Dispatchers.IO) {
synchronized(store.proxySettingsLock) {
ProxySummary(
store.socksBindAddress,
store.socksPort,
store.upstreamProxyEnabled,
store.upstreamProxyType,
Expand All @@ -51,6 +55,7 @@ class ProxySettingsRepository(private val store: SettingsStore) {
withContext(Dispatchers.IO) {
synchronized(store.proxySettingsLock) {
LocalProxyOptions(
store.socksBindAddress,
store.socksPort,
store.localProxyAuthEnabled,
store.localProxyUsername,
Expand Down
Loading
Loading