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
14 changes: 11 additions & 3 deletions app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,20 @@ abstract class DeviceTestBase {
if (VpnService.prepare(context) != null) systemButton("android:id/button1")
await("Real VPN did not connect") { ProxyVpnService.isRunning && vpnPresent() && VpnRuntimeState.connection.value == VpnConnectionState.CONNECTED }
saved()
val routing = store.globalConnectionSettings()
if (routing.routeAllApps || context.packageName in routing.selectedPackages) {
val manager = context.getSystemService(ConnectivityManager::class.java)
// Service readiness precedes Android installing the calling UID's default route.
await("VPN did not become the test process default network") {
manager.getNetworkCapabilities(manager.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}
}
}
protected fun roundTrip() {
protected fun roundTrip(host: String = argument("originHost")) {
val payload = "MegaProxy device ${System.nanoTime()}"
Socket().use { socket ->
socket.soTimeout = 15_000
socket.connect(InetSocketAddress(argument("originHost"), 8080), 15_000)
socket.connect(InetSocketAddress(host, 8080), 15_000)
val request = "POST /echo HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\nContent-Length: ${payload.length}\r\n\r\n$payload"
socket.getOutputStream().write(request.toByteArray(Charsets.US_ASCII))
val response = socket.getInputStream().bufferedReader(Charsets.US_ASCII).readText()
Expand All @@ -114,7 +122,7 @@ abstract class DeviceTestBase {
}
// A successful round trip to the private origin proves Android has
// installed this UID's VPN route before the one-shot UDP DNS probe.
dnsPolicyRoundTrip()
if (!store.activeProfile().config.allowIpv6) dnsPolicyRoundTrip()
}
// Local AAAA suppression proves both DNS transports reach the native TUN
// interceptor. Network DoH/fallback is tested by the native TLS fixtures.
Expand Down
75 changes: 75 additions & 0 deletions app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,81 @@ import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class VpnDeviceTest : DeviceTestBase() {
@Test fun ipv6DestinationsThroughEveryProxyType() = ipv6Traffic(false)
@Test fun ipv6ProxiesAndDestinationsThroughEveryProxyType() = ipv6Traffic(true)

private fun ipv6Traffic(ipv6Proxy: Boolean) {
val target = argument("originIpv6")
for (type in ProxyType.entries) {
assertFalse("IPv6 origin reachable without VPN", runCatching {
java.net.Socket().use { it.connect(java.net.InetSocketAddress(target, 8080), 1_000) }
}.isSuccess)
io {
val profile = store.activeProfile()
val port = argument(when (type) {
ProxyType.SSH, ProxyType.SSH_JUMP -> "sshPort"
ProxyType.SOCKS5 -> "socksPort"
ProxyType.MASQUE -> "masquePort"
else -> "proxyPort"
}).toInt()
val proxyHost = argument(when (type) {
ProxyType.SSH, ProxyType.SSH_JUMP -> "sshIpv6"
ProxyType.SOCKS5 -> "socks5Ipv6"
ProxyType.MASQUE -> "masqueIpv6"
else -> "httpsIpv6"
})
val proxyPort = when (type) {
ProxyType.SSH, ProxyType.SSH_JUMP -> 2222
ProxyType.SOCKS5 -> 1080
else -> 8443
}
store.saveProfile(profile.copy(config = profile.config.copy(
type = type,
host = if (ipv6Proxy) "[$proxyHost]" else when (type) {
ProxyType.HTTPS_JUMP -> argument("tcpExitHost")
ProxyType.SSH_JUMP -> argument("sshExitHost")
else -> "10.0.2.2"
},
port = if (ipv6Proxy) proxyPort else when (type) {
ProxyType.HTTPS_JUMP -> 8443
ProxyType.SSH_JUMP -> 2222
else -> port
}, allowIpv6 = true,
username = if (type.isSsh) "proxy" else "exit", password = argument("proxyPassword"),
trustedHostKey = argument("sshFingerprint"),
jumpHost = if (ipv6Proxy) proxyHost else "10.0.2.2",
jumpPort = if (ipv6Proxy) proxyPort else port,
jumpTrustedHostKey = argument("sshFingerprint"),
jumpAllowInvalidProxyCertificate = true,
allowInvalidProxyCertificate = true, preferHttp3 = false,
)))
}
saved()
connect()
roundTrip(target)
if (type == ProxyType.SOCKS5 || type == ProxyType.MASQUE) {
DatagramSocket().use { socket ->
socket.soTimeout = 10_000
val payload = "IPv6 TUN UDP".toByteArray()
val address = InetAddress.getByName(target)
socket.send(DatagramPacket(payload, payload.size, address, 8081))
val reply = DatagramPacket(ByteArray(128), 128)
if (type == ProxyType.MASQUE) {
// GOST 3.3.0 rejects IPv6 CONNECT-UDP; a direct fallback must not deliver it.
assertThrows(java.net.SocketTimeoutException::class.java) { socket.receive(reply) }
} else {
socket.receive(reply)
assertEquals(address, reply.address)
assertEquals(8081, reply.port)
assertArrayEquals(payload, reply.data.copyOf(reply.length))
}
}
}
click(R.string.disconnect)
stopped()
}
}

@Test fun socks5AuthenticatedTcpUdpStopAndRestart() = socks5Traffic(true)
@Test fun socks5AnonymousTcpUdpStopAndRestart() = socks5Traffic(false)

Expand Down
5 changes: 3 additions & 2 deletions app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ object ConfigTransfer {
else -> "https://"
})
if (config.type != ProxyType.SOCKS5 || config.username.isNotEmpty() || password.isNotEmpty()) append(userInfo).append('@')
append(config.host.trim())
val host = config.host.trim().removeSurrounding("[", "]")
append(if (':' in host) "[$host]" else host)
if (config.port != config.type.defaultPort) append(':').append(config.port)
if (query.isNotEmpty()) append('?').append(query)
}
Expand Down Expand Up @@ -292,7 +293,7 @@ object ConfigTransfer {
val dns = item.optJSONObject("dns") ?: JSONObject()
val routing = item.optJSONObject("routing") ?: JSONObject()
val host = proxy.limitedString("host", 253).trim()
requireUi(host.isNotEmpty() && !host.contains(Regex("[/:\\s]")))
requireUi(net.megaproxy487.model.validProxyHost(host))
val type = enumValue(proxy.optString("type"), ProxyType.HTTPS)
val browser = item.optJSONObject("browser")
val probeOptions = if (proxy.has("knockHost")) proxy else browser
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/net/megaproxy487/model/ProxyConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ data class ProxyConfig(
mtu !in VPN_MTU_RANGE -> R.string.mtu_invalid
unavailableAuthentication() -> R.string.error_credentials_unavailable
host.isBlank() -> if (!type.isSsh) R.string.validation_proxy_host else R.string.validation_ssh_host
host.contains(Regex("[/:\\s]")) -> R.string.validation_host_format
!validProxyHost(host) -> R.string.validation_host_format
port !in 1..65535 -> R.string.validation_port
type == ProxyType.HTTPS && !validAuthProbeHost(authProbeHost.trim()) -> R.string.validation_auth_probe_host
type.isHttpProxy && username.isBlank() -> R.string.validation_basic_username
Expand All @@ -82,7 +82,7 @@ data class ProxyConfig(
type == ProxyType.SOCKS5 && ((username.isEmpty() != password.isEmpty()) ||
username.toByteArray(Charsets.UTF_8).size > 255 || password.toByteArray(Charsets.UTF_8).size > 255) -> R.string.validation_socks5_auth
type.hasJump && jumpHost.isBlank() -> R.string.validation_jump_host
type.hasJump && jumpHost.contains(Regex("[/:\\s]")) -> R.string.validation_jump_host_format
type.hasJump && !validProxyHost(jumpHost) -> R.string.validation_jump_host_format
type.hasJump && jumpPort !in 1..65535 -> R.string.validation_jump_port
type == ProxyType.SSH_JUMP && !sameJumpAuthentication && jumpUsername.isBlank() -> R.string.validation_jump_ssh_username
type == ProxyType.HTTPS_JUMP && !sameJumpAuthentication && jumpUsername.isBlank() -> R.string.validation_jump_basic_username
Expand Down Expand Up @@ -285,6 +285,10 @@ data class Ja3Spec(
}
}

fun validProxyHost(host: String): Boolean = host.isNotEmpty() && if (':' in host) {
validAuthProbeHost(host)
} else !host.contains(Regex("[/\\s\\[\\]@?#%]"))

internal fun validDohUrl(value: String): Boolean = runCatching {
val uri = URI(value)
uri.scheme.equals("https", ignoreCase = true) && !uri.host.isNullOrBlank() &&
Expand Down
33 changes: 33 additions & 0 deletions app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,39 @@ import org.junit.Assert.*
import org.junit.Test

class ProfileEditorUiTest : MainUiTestBase() {
@Test fun ipv6InputsPersistAndRejectMalformedAddressesForEveryProtocol() {
val id = store.activeProfileId()
content { ProfileEditorScreen(activity, id, {}) }
for (type in ProxyType.entries) {
if (store.profile(id)!!.config.type != type) selectOption(R.string.profile_type, type.titleRes)
val label = when (type) {
ProxyType.HTTPS_JUMP -> R.string.destination_https_proxy_hostname
ProxyType.SOCKS5 -> R.string.socks5_proxy_hostname
ProxyType.MASQUE -> R.string.masque_proxy_hostname
ProxyType.HTTPS -> R.string.https_proxy_hostname
else -> R.string.destination_ssh_hostname
}
for (host in listOf("2001:db8::1", "[2001:db8::2]")) {
field(label).performTextReplacement(host)
saved()
node(R.string.validation_host_format).assertDoesNotExist()
assertEquals(host, store.profile(id)!!.config.host)
}
field(label).performTextReplacement("[::1]:443")
saved()
node(R.string.validation_host_format).assertExists()
field(label).performTextReplacement("::1")
saved()
if (type.hasJump) {
val jumpLabel = if (type == ProxyType.HTTPS_JUMP) R.string.https_jump_hostname else R.string.jump_ssh_hostname
field(jumpLabel).performTextReplacement("[2001:db8::3]")
saved()
assertEquals("[2001:db8::3]", store.profile(id)!!.config.jumpHost)
node(R.string.validation_jump_host_format).assertDoesNotExist()
}
}
}

private fun field(id: Int): SemanticsNodeInteraction {
compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(text(id)))
return node(id)
Expand Down
22 changes: 22 additions & 0 deletions app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ import org.junit.Assert.assertTrue
import org.junit.Test

class ConfigTransferTest {
@Test fun ipv6AddressesRoundTripThroughJsonAndUrlExports() {
for (type in ProxyType.entries) for (host in listOf("2001:db8::1", "[2001:db8::1]")) {
val profile = ProxyProfile(id = "ipv6", colorIndex = 0, config = ProxyConfig(
type = type, host = host, port = 8443, username = "user", password = "password", jumpHost = host))
val encoded = ConfigTransfer.encodeProfile(profile, true, false)
val raw = JSONObject().put("schema", ConfigTransfer.SCHEMA_ID).put("version", ConfigTransfer.SCHEMA_VERSION)
.put("profiles", JSONArray().put(encoded)).toString()
ConfigSchemas.assertValid(raw)
val imported = ConfigTransfer.importJson(raw).profiles.single().config
assertEquals(host, imported.host)
if (type.hasJump) assertEquals(host, imported.jumpHost)
if (type == ProxyType.HTTPS || type == ProxyType.MASQUE || type == ProxyType.SOCKS5) {
val uri = java.net.URI(ConfigTransfer.exportProxyList(listOf(profile), true).trim())
assertEquals("[2001:db8::1]", uri.host)
assertEquals(8443, uri.port)
val fromUrl = ProxyListParser.parse(uri.toString()).getOrThrow().proxies.single().config
assertEquals("[2001:db8::1]", fromUrl.host)
assertEquals(null, fromUrl.validationError())
}
}
}

@Test fun authenticationProbeUsesSharedFieldWithLegacyFallbackAndExplicitDisable() {
val config = ProxyConfig(host = "proxy.example", username = "private-user", password = "private-password", authProbeHost = "knock.example")
val encoded = ConfigTransfer.encodeProfile(ProxyProfile(id = "probe", colorIndex = 0, config = config), true, false)
Expand Down
16 changes: 16 additions & 0 deletions app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ import org.junit.Assert.assertNull
import org.junit.Test

class ProxyConfigTest {
@Test fun ipv6ProxyAddressesWorkForEveryTransport() {
for (type in ProxyType.entries) {
val base = net.megaproxy487.model.ProxyConfig(type = type, host = "proxy.example",
username = "user", password = "password", jumpHost = "jump.example")
for (host in listOf("2001:db8::1", "[2001:db8::1]", "::1", "::ffff:192.0.2.1")) {
for (enabled in listOf(false, true)) {
assertNull(base.copy(host = host, jumpHost = host, allowIpv6 = enabled).validationError())
}
}
for (host in listOf("2001:db8:::1", "[::1]:22", "host:443", "https://[::1]", "::1/path", "[::1", "::1%eth0")) {
assertEquals(R.string.validation_host_format, base.copy(host = host).validationError())
if (type.hasJump) assertEquals(R.string.validation_jump_host_format, base.copy(jumpHost = host).validationError())
}
}
}

@Test fun authenticationProbeHostnameIsOptionalAndRejectsUrlsPortsAndControlCharacters() {
for (host in listOf("", "knock.example", "127.0.0.1", "localhost")) assertTrue(validAuthProbeHost(host))
for (host in listOf("https://knock.example", "name:443", "user@host", "host/path", "host?query",
Expand Down
4 changes: 4 additions & 0 deletions docs/en/dns.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ approvals; a changed resolver requires new approval.
Query tokens in custom DoH URLs remain in exported JSON even when passwords are omitted.
Treat the file as sensitive.
Proxy bootstrap DNS precedes the VPN; see the README endpoint inventory.
Bootstrap prefers A records. If no resolver supplies A and at least one returns a valid response
without A, it queries AAAA and can connect to an IPv6-only proxy or jump host. DNS/HTTP errors alone
do not trigger this fallback. Numeric IPv4/IPv6 addresses bypass DNS. Bootstrap DoH endpoints still
use their pinned IPv4 addresses, so an IPv6-only uplink without IPv4 reachability is not covered.

DNS admission is limited to 64 flows per proxy, shared by intercepted TCP and UDP. When full,
the oldest completed UDP flow is evicted together with its TUN mapping. Pending responses
Expand Down
10 changes: 10 additions & 0 deletions docs/en/release-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ scope of verification in the relevant PR/release; never include credentials or p

## Local checks by change

IPv6 regression tests cover editor inputs, JSON/URL exports, native configuration and literal
bootstrap. Real GOST/OpenSSH tests vary IPv4/IPv6 proxy, jump and TCP destination addresses;
SOCKS5 UDP covers both destination families. GOST 3.3.0 MASQUE IPv6 CONNECT-UDP is an expected
rejection, not a supported round trip. A controlled MASQUE relay separately forwards datagrams to
a real IPv6 UDP origin to test client support. API 26/35 tests exercise IPv6 destinations through all six
proxy types via TUN/JNI with both IPv4 and IPv6 proxy/jump endpoints, including SOCKS5 UDP and
MASQUE UDP rejection. Bootstrap tests cover A preference, AAAA-only fallback, malformed/error
responses and use of a resolved IPv6 address. Physical-device
IPv6 uplinks need a separate check. Adding tests is not evidence of passing runs; record CI results.

| Change | Check |
| --- | --- |
| Documentation / artwork only | Local links, images, EN/RU consistency and claims against source; no APK build |
Expand Down
5 changes: 5 additions & 0 deletions docs/ru/dns.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ TCP/UDP-адреса и не должны трактовать их как DoH.
Токены в параметрах своего DoH URL остаются в JSON даже при экспорте без паролей.
Такой файл содержит секретные данные.
Начальное разрешение имени прокси происходит до создания VPN; сервисы перечислены в README.
Bootstrap предпочитает A-записи. Если ни один резолвер не вернул A и хотя бы один дал корректный
ответ без A, запрашиваются AAAA: так можно подключиться к IPv6-only прокси или промежуточному узлу.
Одни лишь DNS/HTTP-ошибки такой фолбек не запускают. Числовые IPv4/IPv6-адреса не требуют DNS.
Bootstrap DoH по-прежнему использует закреплённые IPv4-адреса провайдеров: IPv6-only uplink без
доступности IPv4 этим изменением не покрывается.

На прокси допускается до 64 DNS-потоков суммарно для перехваченного TCP и UDP. При заполнении
лимита удаляется самый старый завершённый UDP-поток вместе с его TUN-соединением. Потоки
Expand Down
11 changes: 11 additions & 0 deletions docs/ru/release-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@

## Локальные проверки по изменению

Регрессионные тесты IPv6 проверяют поля редактора, экспорт JSON/URL, нативную конфигурацию и
bootstrap числовых адресов. Тесты с настоящими GOST/OpenSSH варьируют IPv4/IPv6 адреса прокси,
промежуточного узла и TCP-назначения; SOCKS5 UDP проверяет оба семейства назначения. Отказ GOST
3.3.0 для MASQUE IPv6 CONNECT-UDP ожидаем и не считается успешным обменом. Отдельный управляемый
MASQUE-сервер пересылает датаграммы настоящему IPv6 UDP origin для проверки клиента. Тесты API 26/35
проверяют IPv6-назначения через все шесть типов прокси и TUN/JNI с IPv4- и IPv6-адресами прокси
и промежуточных узлов, включая SOCKS5 UDP и отказ
MASQUE UDP. Bootstrap-тесты проверяют приоритет A, фолбек на AAAA, ошибки и повреждённые ответы,
а также использование разрешённого IPv6-адреса. IPv6 uplink физического устройства требует отдельной проверки.
Наличие тестов не доказывает успешный прогон: результаты CI нужно фиксировать отдельно.

| Изменение | Проверка |
| --- | --- |
| Только документация / графика | Локальные ссылки, картинки, согласованность EN/RU и соответствие коду; без сборки APK |
Expand Down
Loading
Loading