diff --git a/jni/AndroidTunnelController.kt b/jni/AndroidTunnelController.kt index affe82c6..8e008c31 100644 --- a/jni/AndroidTunnelController.kt +++ b/jni/AndroidTunnelController.kt @@ -36,6 +36,7 @@ class AndroidTunnelController: AutoCloseable { builder.addAddress(address, prefix) } + // FIXME: Pass Profile fun build(infoJSON: String): Int? { assert(descriptor == null) @@ -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 -> {} } @@ -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 // diff --git a/jni/DNSModule+Vpn.kt b/jni/DNSModule+Vpn.kt new file mode 100644 index 00000000..8f3f5769 --- /dev/null +++ b/jni/DNSModule+Vpn.kt @@ -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() +} diff --git a/jni/HTTPProxyModule+Vpn.kt b/jni/HTTPProxyModule+Vpn.kt new file mode 100644 index 00000000..22bcf862 --- /dev/null +++ b/jni/HTTPProxyModule+Vpn.kt @@ -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 { + 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 +} diff --git a/jni/IPModule+Vpn.kt b/jni/IPModule+Vpn.kt new file mode 100644 index 00000000..c0e320b0 --- /dev/null +++ b/jni/IPModule+Vpn.kt @@ -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() +} diff --git a/jni/OnDemandModule+Vpn.kt b/jni/OnDemandModule+Vpn.kt new file mode 100644 index 00000000..df6325de --- /dev/null +++ b/jni/OnDemandModule+Vpn.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Davide De Rosa +// +// SPDX-License-Identifier: GPL-3.0 + +package io.partout.jni + +import android.net.VpnService +import android.util.Log +import io.partout.abi.OnDemandModule + +private const val logTag = "Partout" + +@Suppress("UNUSED_PARAMETER") +internal fun OnDemandModule.apply(builder: VpnService.Builder) { + Log.i(logTag, "OnDemand: Android VpnService.Builder does not expose on-demand rules, skipping") +}