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
21 changes: 10 additions & 11 deletions jni/AndroidTunnelController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class AndroidTunnelController: AutoCloseable {
builder.addAddress(address, prefix)
}

// FIXME: Pass Profile
fun build(infoJSON: String): Int? {
assert(descriptor == null)

Expand All @@ -48,20 +49,26 @@ class AndroidTunnelController: AutoCloseable {
return null
}
val remoteFds = info.fileDescriptors
var appliedAddressSettings = false
var appliedDnsSettings = false

info.modules?.forEach {
when (it) {
is TaggedModuleDNS -> {
Log.i(logTag, "DNS: ${it.value}")
appliedDnsSettings = it.value.apply(builder) || appliedDnsSettings
}
is TaggedModuleIP -> {
Log.i(logTag, "IP: ${it.value}")
appliedAddressSettings = it.value.apply(builder) || appliedAddressSettings
}
is TaggedModuleHTTPProxy -> {
Log.i(logTag, "HTTP Proxy: ${it.value}")
it.value.apply(builder)
}
is TaggedModuleOnDemand -> {
Log.i(logTag, "OnDemand: ${it.value}")
it.value.apply(builder)
}
else -> {}
}
Expand All @@ -73,17 +80,9 @@ class AndroidTunnelController: AutoCloseable {
service.protect(it)
}

// FIXME: hardcode network settings to try tun fd
// builder.setSession()
builder
// OpenVPN
.addAddress("10.8.0.2", 24)
.addRoute("10.8.0.0", 24)
// WireGuard
// .addAddress("192.168.30.2", 32)
// All
.addRoute("0.0.0.0", 0)
.addDnsServer("1.1.1.1")
// FIXME: Register callback to update protected sockets?
// FIXME: Set session name from profile name
// builder.setSession(profile.name)

// IMPORTANT: this is a requirement for VirtualTunnelInterface
//
Expand Down
139 changes: 139 additions & 0 deletions jni/DNSModule+Vpn.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: 2026 Davide De Rosa
//
// SPDX-License-Identifier: GPL-3.0

package io.partout.jni

import android.net.IpPrefix
import android.net.VpnService
import android.os.Build
import android.util.Log
import io.partout.abi.DNSModule
import io.partout.abi.DNSModuleProtocolTypehttps
import io.partout.abi.DNSModuleProtocolTypetls
import io.partout.abi.Route
import java.net.InetAddress

private const val logTag = "Partout"

internal fun DNSModule.apply(builder: VpnService.Builder): Boolean {
var applied = when (protocolType) {
is DNSModuleProtocolTypehttps, is DNSModuleProtocolTypetls -> true
else -> servers.isNotEmpty()
}

when (protocolType) {
is DNSModuleProtocolTypehttps -> {
Log.i(logTag, "DNS: DoH is not supported by VpnService.Builder, using numeric servers only")
addServers(builder, routed = routesThroughVPN == true)
}
is DNSModuleProtocolTypetls -> {
Log.i(logTag, "DNS: DoT is not supported by VpnService.Builder, using numeric servers only")
addServers(builder, routed = routesThroughVPN == true)
}
else -> {
if (servers.isNotEmpty()) {
addServers(builder, routed = routesThroughVPN == true)
} else {
Log.i(logTag, "DNS: cleartext DNS without servers is ignored")
}
}
}

if (!applied) {
return false
}

domainName?.takeIf { it.isNotBlank() }?.let {
Log.i(logTag, "DNS: Search domain (domainName): $it")
builder.addSearchDomain(it)
}

searchDomains.orEmpty().forEach { domain ->
if (domain.isNotBlank()) {
Log.i(logTag, "DNS: Search domain: $domain")
builder.addSearchDomain(domain)
}
}

return applied
}

fun DNSModule.addServers(
builder: VpnService.Builder,
routed: Boolean
) {
servers.forEach { server ->
val route = subnetFrom(server)
if (route == null) {
Log.w(logTag, "DNS: Ignoring invalid server '$server'")
return@forEach
}

Log.i(logTag, "DNS: Server: ${route.address}/${route.prefixLength}")
builder.addDnsServer(route.address)

when {
routed -> {
Log.i(logTag, "DNS: Route server through VPN: ${route.address}/${route.prefixLength}")
builder.addRoute(route.address, route.prefixLength)
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
Log.i(logTag, "DNS: Keep server outside VPN: ${route.address}/${route.prefixLength}")
route.toIpPrefix()?.let {
builder.excludeRoute(it)
} ?: Log.w(logTag, "DNS: Unable to build route exclusion for '$server'")
}
else -> {
Log.i(logTag, "DNS: Cannot exclude DNS server route before API 33: ${route.address}/${route.prefixLength}")
}
}
}
}

private data class DnsNumericSubnet(
val address: String,
val prefixLength: Int
)

private fun subnetFrom(raw: String): DnsNumericSubnet? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) {
return null
}

val parts = trimmed.split("/", limit = 2)
val address = parts[0].trim()
val prefixLength = parts.getOrNull(1)?.toIntOrNull() ?: defaultPrefixLength(address)
if (prefixLength == null) {
return null
}
if (!isNumericAddress(address)) {
return null
}
return DnsNumericSubnet(address, prefixLength)
}

private fun defaultPrefixLength(address: String): Int? {
return when {
address.contains(":") -> 128
address.contains(".") -> 32
else -> null
}
}

private fun isNumericAddress(address: String): Boolean {
return address.contains(":") || address.matches(Regex("""\d{1,3}(\.\d{1,3}){3}"""))
}

private fun DnsNumericSubnet.toIpPrefix(): IpPrefix? {
return runCatching {
IpPrefix(InetAddress.getByName(address), prefixLength)
}.getOrNull()
}

private fun Route.toIpPrefix(): IpPrefix? {
val destination = destination ?: return null
val subnet = subnetFrom(destination) ?: return null
return subnet.toIpPrefix()
}
52 changes: 52 additions & 0 deletions jni/HTTPProxyModule+Vpn.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2026 Davide De Rosa
//
// SPDX-License-Identifier: GPL-3.0

package io.partout.jni

import android.net.ProxyInfo
import android.net.VpnService
import android.os.Build
import android.util.Log
import io.partout.abi.HTTPProxyModule

private const val logTag = "Partout"

internal fun HTTPProxyModule.apply(builder: VpnService.Builder) {
val endpoint = proxy ?: secureProxy
if (endpoint == null) {
if (pacURL != null) {
Log.i(logTag, "HTTP Proxy: PAC is not supported by VpnService.Builder, skipping")
} else {
Log.i(logTag, "HTTP Proxy: no proxy configured")
}
return
}

if (proxy != null && secureProxy != null && proxy != secureProxy) {
Log.i(logTag, "HTTP Proxy: both HTTP and HTTPS proxies are set; Android can use only one proxy, preferring HTTP")
}

val (host, port) = endpoint.asHostPort()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Log.i(logTag, "HTTP Proxy: setHttpProxy is unavailable before API 29, skipping")
return
}
val proxyInfo = ProxyInfo.buildDirectProxy(host, port, bypassDomains.toMutableList())
Log.i(logTag, "HTTP Proxy: proxy=$host:$port bypass=${bypassDomains.joinToString()}")
builder.setHttpProxy(proxyInfo)

if (pacURL != null) {
Log.i(logTag, "HTTP Proxy: PAC URL is ignored on Android VPNs: $pacURL")
}
}

private fun String.asHostPort(): Pair<String, Int> {
val idx = lastIndexOf(':')
if (idx <= 0 || idx == lastIndex) {
return this to 0
}
val host = substring(0, idx)
val port = substring(idx + 1).toIntOrNull() ?: 0
return host to port
}
143 changes: 143 additions & 0 deletions jni/IPModule+Vpn.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// SPDX-FileCopyrightText: 2026 Davide De Rosa
//
// SPDX-License-Identifier: GPL-3.0

package io.partout.jni

import android.net.IpPrefix
import android.net.VpnService
import android.os.Build
import android.util.Log
import io.partout.abi.IPModule
import io.partout.abi.IPSettings
import io.partout.abi.Route
import java.net.InetAddress

private const val logTag = "Partout"

internal fun IPModule.apply(builder: VpnService.Builder): Boolean {
var addedAddress = false

ipv4?.let {
addedAddress = it.apply(builder, isIPv6 = false) || addedAddress
}
ipv6?.let {
addedAddress = it.apply(builder, isIPv6 = true) || addedAddress
}

mtu?.takeIf { it > 0 }?.let {
Log.i(logTag, "IP: MTU = $it")
builder.setMtu(it)
}

return addedAddress
}

private fun IPSettings.apply(builder: VpnService.Builder, isIPv6: Boolean): Boolean {
var addedAddress = false

subnets.forEach { rawSubnet ->
val subnet = subnetFrom(rawSubnet)
if (subnet == null) {
Log.w(logTag, "IP: Ignoring invalid subnet '$rawSubnet'")
return@forEach
}
Log.i(logTag, "IP: Address = ${subnet.address}/${subnet.prefixLength}")
builder.addAddress(subnet.address, subnet.prefixLength)
addedAddress = true
}

includedRoutes.forEach { route ->
route.apply(builder, isExcluded = false, isIPv6 = isIPv6)
}

excludedRoutes.forEach { route ->
route.apply(builder, isExcluded = true, isIPv6 = isIPv6)
}

return addedAddress
}

private fun Route.apply(builder: VpnService.Builder, isExcluded: Boolean, isIPv6: Boolean) {
val prefix = destinationPrefix(isIPv6) ?: run {
Log.w(logTag, "IP: Ignoring invalid ${if (isExcluded) "excluded" else "included"} route '$this'")
return
}

if (gateway != null) {
Log.i(logTag, "IP: Route gateway is ignored on Android VPNs: ${gateway}")
}

when {
isExcluded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
Log.i(logTag, "IP: Exclude route ${prefix.address}/${prefix.prefixLength}")
prefix.toIpPrefix()?.let {
builder.excludeRoute(it)
} ?: Log.w(logTag, "IP: Unable to build route exclusion for '$this'")
}
isExcluded -> {
Log.i(logTag, "IP: Cannot exclude route before API 33: ${prefix.address}/${prefix.prefixLength}")
}
else -> {
Log.i(logTag, "IP: Include route ${prefix.address}/${prefix.prefixLength}")
builder.addRoute(prefix.address, prefix.prefixLength)
}
}
}

private data class IpNumericSubnet(
val address: String,
val prefixLength: Int
)

private data class IpPrefixRoute(
val address: String,
val prefixLength: Int
)

private fun Route.destinationPrefix(isIPv6: Boolean): IpPrefixRoute? {
val raw = destination?.trim()
if (raw.isNullOrEmpty()) {
return IpPrefixRoute(
address = if (isIPv6) "::" else "0.0.0.0",
prefixLength = 0
)
}
return subnetFrom(raw)?.let { IpPrefixRoute(it.address, it.prefixLength) }
}

private fun subnetFrom(raw: String): IpNumericSubnet? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) {
return null
}

val parts = trimmed.split("/", limit = 2)
val address = parts[0].trim()
val prefixLength = parts.getOrNull(1)?.toIntOrNull() ?: defaultPrefixLength(address)
if (prefixLength == null) {
return null
}
if (!isNumericAddress(address)) {
return null
}
return IpNumericSubnet(address, prefixLength)
}

private fun defaultPrefixLength(address: String): Int? {
return when {
address.contains(":") -> 128
address.contains(".") -> 32
else -> null
}
}

private fun isNumericAddress(address: String): Boolean {
return address.contains(":") || address.matches(Regex("""\d{1,3}(\.\d{1,3}){3}"""))
}

private fun IpPrefixRoute.toIpPrefix(): IpPrefix? {
return runCatching {
IpPrefix(InetAddress.getByName(address), prefixLength)
}.getOrNull()
}
Loading