From ccc0e07bbbef40434286b506273b333c98098a7d Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 10 Nov 2026 17:58:14 +0300 Subject: [PATCH 1/5] Fix IPv6 proxy literals and cover proxy and destination address families --- .../java/net/megaproxy487/DeviceTestBase.kt | 6 +- .../java/net/megaproxy487/VpnDeviceTest.kt | 60 +++++++++ .../net/megaproxy487/data/ConfigTransfer.kt | 5 +- .../net/megaproxy487/model/ProxyConfig.kt | 8 +- .../net/megaproxy487/ProfileEditorUiTest.kt | 33 +++++ .../megaproxy487/data/ConfigTransferTest.kt | 22 ++++ .../net/megaproxy487/model/ProxyConfigTest.kt | 16 +++ docs/en/release-testing.md | 8 ++ docs/ru/release-testing.md | 9 ++ native/integration/origin.py | 25 ++-- native/mobile/bootstrap.go | 2 +- native/mobile/bootstrap_test.go | 9 ++ native/mobile/config.go | 19 ++- native/mobile/config_test.go | 32 +++++ native/mobile/real_servers_test.go | 117 +++++++++++++++++- scripts/android_device_tests.py | 20 ++- 16 files changed, 371 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt index a26ad21f..cef638eb 100644 --- a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt +++ b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt @@ -100,11 +100,11 @@ abstract class DeviceTestBase { await("Real VPN did not connect") { ProxyVpnService.isRunning && vpnPresent() && VpnRuntimeState.connection.value == VpnConnectionState.CONNECTED } saved() } - 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() @@ -114,7 +114,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. diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index 870c03ae..1633c550 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -25,6 +25,66 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class VpnDeviceTest : DeviceTestBase() { + @Test fun ipv6DestinationsThroughEveryProxyType() { + 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() + store.saveProfile(profile.copy(config = profile.config.copy( + type = type, + host = when (type) { + ProxyType.HTTPS_JUMP -> argument("tcpExitHost") + ProxyType.SSH_JUMP -> argument("sshExitHost") + else -> "10.0.2.2" + }, + port = 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 = "10.0.2.2", jumpPort = 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) diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index 639453b0..5dc0a6bc 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -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) } @@ -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 diff --git a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt index aaddbffa..a8054f82 100644 --- a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt +++ b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt @@ -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 @@ -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 @@ -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() && diff --git a/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt b/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt index 5ad973cf..33382f67 100644 --- a/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt +++ b/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt @@ -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) { + 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) diff --git a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt index b151b340..2d2b4095 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt @@ -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) diff --git a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt index 8a74c211..9699f630 100644 --- a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt +++ b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt @@ -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", diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 0fbd2a25..924c8f2f 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -9,6 +9,14 @@ 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. API 26/35 tests exercise IPv6 destinations through all six +proxy types via TUN/JNI, including SOCKS5 UDP and MASQUE UDP rejection. These use numeric addresses; +AAAA-only proxy hostname bootstrap remains unsupported by the A-only resolver. 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 | diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index ca1f05de..9b2c0dd4 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -9,6 +9,15 @@ ## Локальные проверки по изменению +Регрессионные тесты IPv6 проверяют поля редактора, экспорт JSON/URL, нативную конфигурацию и +bootstrap числовых адресов. Тесты с настоящими GOST/OpenSSH варьируют IPv4/IPv6 адреса прокси, +промежуточного узла и TCP-назначения; SOCKS5 UDP проверяет оба семейства назначения. Отказ GOST +3.3.0 для MASQUE IPv6 CONNECT-UDP ожидаем и не считается успешным обменом. Тесты API 26/35 +проверяют IPv6-назначения через все шесть типов прокси и TUN/JNI, включая SOCKS5 UDP и отказ +MASQUE UDP. Используются числовые адреса; bootstrap имён только с AAAA пока не поддерживается +резолвером, запрашивающим A. IPv6 uplink физического устройства требует отдельной проверки. +Наличие тестов не доказывает успешный прогон: результаты CI нужно фиксировать отдельно. + | Изменение | Проверка | | --- | --- | | Только документация / графика | Локальные ссылки, картинки, согласованность EN/RU и соответствие коду; без сборки APK | diff --git a/native/integration/origin.py b/native/integration/origin.py index 5e53832a..5ed3b8e7 100644 --- a/native/integration/origin.py +++ b/native/integration/origin.py @@ -3,7 +3,7 @@ import socket import ssl import struct -import subprocess +import subprocess as sp import tempfile import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -33,8 +33,9 @@ def do_POST(self): def udp_echo(): - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: - sock.bind(("0.0.0.0", 8081)) + with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as sock: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + sock.bind(("::", 8081)) while True: payload, address = sock.recvfrom(65535) sock.sendto(payload, address) @@ -75,7 +76,7 @@ def serve(conn): def doh(): with tempfile.TemporaryDirectory() as directory: cert, key = directory + "/cert.pem", directory + "/key.pem" - subprocess.run( + sp.run( [ "openssl", "req", @@ -93,8 +94,8 @@ def doh(): cert, ], check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=sp.DEVNULL, + stderr=sp.DEVNULL, ) server = ThreadingHTTPServer(("0.0.0.0", 8444), Echo) context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) @@ -105,4 +106,14 @@ def doh(): for target in (udp_echo, dns_udp, dns_tcp, doh): threading.Thread(target=target, daemon=True).start() -ThreadingHTTPServer(("0.0.0.0", 8080), Echo).serve_forever() + + +class DualStackHTTPServer(ThreadingHTTPServer): + address_family = socket.AF_INET6 + + def server_bind(self): + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + super().server_bind() + + +DualStackHTTPServer(("::", 8080), Echo).serve_forever() diff --git a/native/mobile/bootstrap.go b/native/mobile/bootstrap.go index 9833c057..39b55c20 100644 --- a/native/mobile/bootstrap.go +++ b/native/mobile/bootstrap.go @@ -34,7 +34,7 @@ var bootstrapResolvers = []bootstrapResolver{ // ResolveProxy bootstraps the proxy address through protected encrypted DNS. func ResolveProxy(host string, protector Protector, reporter Reporter) (string, error) { - host = strings.TrimSpace(host) + host = normalizeProxyHost(host) if ip := net.ParseIP(host); ip != nil { return ip.String(), nil } diff --git a/native/mobile/bootstrap_test.go b/native/mobile/bootstrap_test.go index 2004b7d0..93eab15d 100644 --- a/native/mobile/bootstrap_test.go +++ b/native/mobile/bootstrap_test.go @@ -11,6 +11,15 @@ import ( "time" ) +func TestBootstrapIPv6LiteralDoesNotUseDNS(t *testing.T) { + for _, host := range []string{"2001:db8::1", "[2001:db8::1]"} { + got, err := ResolveProxy(host, nil, nil) + if err != nil || got != "2001:db8::1" { + t.Fatalf("literal bootstrap: %q %v", got, err) + } + } +} + func TestBootstrapResolversUseDirectHTTPSAddresses(t *testing.T) { if len(bootstrapResolvers) < 3 { t.Fatalf("bootstrap resolver count = %d, want failover", len(bootstrapResolvers)) diff --git a/native/mobile/config.go b/native/mobile/config.go index 31d3f3a8..51ab4767 100644 --- a/native/mobile/config.go +++ b/native/mobile/config.go @@ -70,12 +70,13 @@ func parseConfig(raw string) (config, error) { if !validAuthProbeHost(c.AuthProbeHost) { return c, errors.New("invalid diagnostic probe hostname") } - c.Host = strings.TrimSpace(c.Host) + c.Host = normalizeProxyHost(c.Host) + c.JumpHost = normalizeProxyHost(c.JumpHost) c.DialHost = strings.TrimSpace(c.DialHost) if c.Type == "" { c.Type = "HTTPS" } - if c.Host == "" || strings.ContainsAny(c.Host, "/: \t\r\n") { + if !validProxyHost(c.Host) { return c, errors.New("invalid proxy hostname") } if c.Type != "HTTPS_JUMP" && net.ParseIP(c.DialHost) == nil { @@ -97,7 +98,7 @@ func parseConfig(raw string) (config, error) { return c, errors.New("SOCKS5 requires no credentials or a username and password of 1 to 255 UTF-8 bytes each") } if c.Type == "SSH_JUMP" || c.Type == "HTTPS_JUMP" { - if c.JumpHost == "" || strings.ContainsAny(c.JumpHost, "/: \t\r\n") { + if !validProxyHost(c.JumpHost) { return c, errors.New("invalid jump hostname") } if net.ParseIP(c.JumpDialHost) == nil { @@ -164,6 +165,18 @@ func parseConfig(raw string) (config, error) { return c, nil } +func normalizeProxyHost(host string) string { + host = strings.TrimSpace(host) + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") && net.ParseIP(host[1:len(host)-1]) != nil { + return host[1 : len(host)-1] + } + return host +} + +func validProxyHost(host string) bool { + return net.ParseIP(host) != nil || (host != "" && !strings.ContainsAny(host, "/: \t\r\n[]@?#%")) +} + func (c config) isHTTPProxy() bool { return c.isHTTPS() || c.Type == "MASQUE" } func (c config) isHTTPS() bool { return c.Type == "HTTPS" || c.Type == "HTTPS_JUMP" } diff --git a/native/mobile/config_test.go b/native/mobile/config_test.go index eb7aa549..b7d594d6 100644 --- a/native/mobile/config_test.go +++ b/native/mobile/config_test.go @@ -6,6 +6,38 @@ import ( "testing" ) +func TestIPv6ProxyConfig(t *testing.T) { + for _, transport := range []string{"HTTPS", "HTTPS_JUMP", "MASQUE", "SOCKS5", "SSH", "SSH_JUMP"} { + for _, host := range []string{"2001:db8::1", "[2001:db8::1]", "2001:db8:::1", "[::1]:443", "host:443", "::1%eth0", "https://[::1]"} { + for _, jump := range []bool{false, true} { + c := config{Type: transport, Host: "proxy.example", DialHost: "2001:db8::1", Port: 443, + Username: "user", Password: "password", Profile: "CHROME_ANDROID", DoHURL: "https://dns.example/query", + JumpHost: "jump.example", JumpDialHost: "2001:db8::2", JumpPort: 443, JumpUsername: "user", JumpPassword: "password"} + if jump { + if transport != "HTTPS_JUMP" && transport != "SSH_JUMP" { + continue + } + c.JumpHost = host + } else { + c.Host = host + } + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + parsed, err := parseConfig(string(raw)) + valid := host == "2001:db8::1" || host == "[2001:db8::1]" + if (err == nil) != valid { + t.Fatalf("%s host=%s jump=%v: %v", transport, host, jump, err) + } + if valid && ((!jump && parsed.Host != "2001:db8::1") || (jump && parsed.JumpHost != "2001:db8::1")) { + t.Fatal("IPv6 brackets not normalized") + } + } + } + } +} + func TestParseJA3(t *testing.T) { s, err := parseJA3("771,4865-4866,0-10-11-13-16-43-51,29-23,0") if err != nil { diff --git a/native/mobile/real_servers_test.go b/native/mobile/real_servers_test.go index 4d2d8834..4fde74b9 100644 --- a/native/mobile/real_servers_test.go +++ b/native/mobile/real_servers_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/rand" "encoding/json" + "errors" "fmt" "io" "net" @@ -37,7 +38,7 @@ func TestRealProxyServers(t *testing.T) { t.Log("Building OpenSSH/HTTP fixture and starting real proxy servers") dockerTest(t, "build", "-t", image, "../integration") t.Cleanup(func() { dockerTest(t, "image", "rm", image) }) - dockerTest(t, "network", "create", id) + dockerTest(t, "network", "create", "--ipv6", "--subnet", fmt.Sprintf("fd42:%x:%x::/64", uint16(time.Now().UnixNano()>>16), uint16(time.Now().UnixNano())), id) t.Cleanup(func() { dockerTest(t, "network", "rm", id) }) start := func(alias, port, serverImage string, args ...string) string { @@ -192,6 +193,120 @@ func TestRealProxyServers(t *testing.T) { sshJumpConfig.JumpDialHost, sshJumpConfig.JumpPort = endpoint(sshJump, "2222") sshJumpConfig.JumpTrustedHostKey = fingerprint(sshJump) + containerIP := func(container string, ipv6 bool) string { + t.Helper() + field := "IPAddress" + if ipv6 { + field = "GlobalIPv6Address" + } + address := strings.TrimSpace(dockerTest(t, "inspect", "--format", "{{range .NetworkSettings.Networks}}{{."+field+"}}{{end}}", container)) + ip, err := netip.ParseAddr(address) + if err != nil || ip.Is6() != ipv6 { + t.Fatalf("missing container address: %q %v", address, err) + } + return address + } + for _, tc := range []struct { + name, container string + cfg config + port int + jumpContainer string + jumpPort int + }{ + {"https", h1, base, 8443, "", 0}, + {"http2", h2, h2Config, 8443, "", 0}, + {"masque", masque, masqueConfig, 8443, "", 0}, + {"socks5", socksAuth, socksConfig, 1080, "", 0}, + {"ssh", sshExit, sshConfig, 2222, "", 0}, + {"https_jump", h1, httpsJump, 8443, h2, 8443}, + {"ssh_jump", sshExit, sshJumpConfig, 2222, sshJump, 2222}, + } { + for _, proxy6 := range []bool{false, true} { + for _, jump6 := range []bool{false, true} { + if jump6 && tc.jumpContainer == "" { + continue + } + for _, target6 := range []bool{false, true} { + t.Run(fmt.Sprintf("ipv6_%s/proxy6=%v/jump6=%v/target6=%v", tc.name, proxy6, jump6, target6), func(t *testing.T) { + cfg := tc.cfg + cfg.Host = containerIP(tc.container, proxy6) + cfg.DialHost, cfg.Port, cfg.AllowIPv6 = cfg.Host, tc.port, true + if tc.jumpContainer != "" { + cfg.JumpHost = containerIP(tc.jumpContainer, jump6) + cfg.JumpDialHost, cfg.JumpPort = cfg.JumpHost, tc.jumpPort + } + targetIP := containerIP(id+"-origin", target6) + target := net.JoinHostPort(targetIP, "8080") + dial := realServerDialer(t, cfg) + transport := &http.Transport{DialContext: func(ctx context.Context, _, address string) (net.Conn, error) { return dial(ctx, address) }} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 15 * time.Second} + payload := bytes.Repeat([]byte("IPv6 round trip"), 4096) + response, err := client.Post("http://"+target+"/echo", "application/octet-stream", bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, int64(len(payload)+1))) + if err != nil || response.StatusCode != 200 || !bytes.Equal(body, payload) { + t.Fatalf("HTTP echo failed: %v", err) + } + if target6 { + blocked := cfg + blocked.AllowIPv6 = false + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, err := realServerDialer(t, blocked)(ctx, target) + if err == nil { + conn.Close() + t.Fatal("IPv4-only mode accepted IPv6 destination") + } + } + if cfg.Type == "SOCKS5" || cfg.Type == "MASQUE" { + var packet net.PacketConn + metadata := &M.Metadata{DstIP: netip.MustParseAddr(targetIP), DstPort: 8081} + if cfg.Type == "SOCKS5" { + d := &socks5Dialer{config: cfg, protector: &jumpTestProtector{}} + defer d.Close() + packet, err = d.DialUDP(metadata) + } else { + d := &masqueDialer{config: cfg, protector: &jumpTestProtector{}} + defer d.Close() + packet, err = d.DialUDP(metadata) + } + if cfg.Type == "MASQUE" && target6 { + // GOST 3.3.0 rejects IPv6 CONNECT-UDP; never accept a direct fallback. + if err == nil { + packet.Close() + t.Fatal("GOST IPv6 UDP limitation changed; require a positive round trip") + } + var rejected *masqueConnectError + if !errors.As(err, &rejected) { + t.Fatalf("expected CONNECT-UDP rejection, got %v", err) + } + return + } + if err != nil { + t.Fatal(err) + } + defer packet.Close() + peer := &net.UDPAddr{IP: net.ParseIP(targetIP), Port: 8081} + packet.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err = packet.WriteTo([]byte("IPv6 UDP"), peer); err != nil { + t.Fatal(err) + } + buffer := make([]byte, 64) + n, from, err := packet.ReadFrom(buffer) + if err != nil || string(buffer[:n]) != "IPv6 UDP" || from.String() != peer.String() { + t.Fatalf("UDP echo failed: %v", err) + } + } + }) + } + } + } + } + // An accidental direct connection must not be able to satisfy a positive test. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", originAddress) diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index e80adc52..37a9277b 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -28,6 +28,7 @@ "VpnDeviceTest#deniedVpnConsentDoesNotStartTunnel", "DnsDeviceTest#resolverModesThroughRealTun", "VpnDeviceTest#trafficStopAndRestart", + "VpnDeviceTest#ipv6DestinationsThroughEveryProxyType", "VpnDeviceTest#socks5AuthenticatedTcpUdpStopAndRestart", "VpnDeviceTest#socks5AnonymousTcpUdpStopAndRestart", "VpnDeviceTest#masqueTrafficStopAndRestart", @@ -193,7 +194,7 @@ def fixture(): blackhole.bind(("127.0.0.1", 0)) command("docker", "build", "-t", image, str(ROOT / "native/integration")) image_built = True - command("docker", "network", "create", name) + command("docker", "network", "create", "--ipv6", name) network_created = True def start(alias, port, server_image, *args): @@ -223,6 +224,15 @@ def start(alias, port, server_image, *args): "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", origin, ) + origin_ipv6 = command( + "docker", + "inspect", + "--format", + "{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}", + origin, + ) + if not origin_ipv6: + raise RuntimeError("IPv6 origin address is missing") # Emulator user-mode networking originates in the host OUTPUT chain. Container # traffic uses FORWARD, so only the proxies can reach the unpublished origin. if sys.platform == "linux": @@ -329,6 +339,7 @@ def port(container, number): ).split()[1] yield { "originHost": origin_ip, + "originIpv6": origin_ipv6, "directDnsPort": str(direct_dns.server_address[1]), "proxyPort": https_port, "socksPort": port(socks_auth, "1080"), @@ -354,6 +365,13 @@ def port(container, number): "blackholeMasquePort": str(blackhole.getsockname()[1]), "proxyPassword": password, "sshPort": ssh_port, + "sshExitHost": command( + "docker", + "inspect", + "--format", + "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + ssh, + ), "sshPassword": password, "sshKey": base64.b64encode(key.encode()).decode(), "sshFingerprint": fingerprint, From a1ba8acb794ad32f0f83af08bcfcb47505b8bc42 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 10 Nov 2026 18:04:09 +0300 Subject: [PATCH 2/5] Verify MASQUE IPv6 UDP origin and reject direct bypass in matrix --- docs/en/release-testing.md | 3 +- docs/ru/release-testing.md | 3 +- native/mobile/masque_test.go | 70 ++++++++++++++++++++++++++++++ native/mobile/real_servers_test.go | 9 ++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 924c8f2f..af550f49 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -12,7 +12,8 @@ scope of verification in the relevant PR/release; never include credentials or p 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. API 26/35 tests exercise IPv6 destinations through all six +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, including SOCKS5 UDP and MASQUE UDP rejection. These use numeric addresses; AAAA-only proxy hostname bootstrap remains unsupported by the A-only resolver. Physical-device IPv6 uplinks need a separate check. Adding tests is not evidence of passing runs; record CI results. diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index 9b2c0dd4..8bfed443 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -12,7 +12,8 @@ Регрессионные тесты IPv6 проверяют поля редактора, экспорт JSON/URL, нативную конфигурацию и bootstrap числовых адресов. Тесты с настоящими GOST/OpenSSH варьируют IPv4/IPv6 адреса прокси, промежуточного узла и TCP-назначения; SOCKS5 UDP проверяет оба семейства назначения. Отказ GOST -3.3.0 для MASQUE IPv6 CONNECT-UDP ожидаем и не считается успешным обменом. Тесты API 26/35 +3.3.0 для MASQUE IPv6 CONNECT-UDP ожидаем и не считается успешным обменом. Отдельный управляемый +MASQUE-сервер пересылает датаграммы настоящему IPv6 UDP origin для проверки клиента. Тесты API 26/35 проверяют IPv6-назначения через все шесть типов прокси и TUN/JNI, включая SOCKS5 UDP и отказ MASQUE UDP. Используются числовые адреса; bootstrap имён только с AAAA пока не поддерживается резолвером, запрашивающим A. IPv6 uplink физического устройства требует отдельной проверки. diff --git a/native/mobile/masque_test.go b/native/mobile/masque_test.go index dfd626dd..5664d69b 100644 --- a/native/mobile/masque_test.go +++ b/native/mobile/masque_test.go @@ -174,6 +174,76 @@ func TestMASQUEStreamsAndDatagrams(t *testing.T) { } } +func TestMASQUEIPv6UDPOrigin(t *testing.T) { + origin, err := net.ListenPacket("udp6", "[::1]:0") + if err != nil { + t.Fatal(err) + } + defer origin.Close() + done := make(chan struct{}) + go func() { + defer close(done) + buffer := make([]byte, 2048) + n, peer, err := origin.ReadFrom(buffer) + if err == nil { + _, _ = origin.WriteTo(append([]byte("origin:"), buffer[:n]...), peer) + } + }() + defer func() { origin.Close(); <-done }() + peer := origin.LocalAddr().(*net.UDPAddr) + fixture := newMasqueFixture(t, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Proto != "connect-udp" || r.URL.Path != fmt.Sprintf("/.well-known/masque/udp/::1/%d/", peer.Port) { + w.WriteHeader(400) + return + } + conn, err := net.DialUDP("udp6", nil, peer) + if err != nil { + w.WriteHeader(502) + return + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + w.Header().Set("Capsule-Protocol", "?1") + w.WriteHeader(200) + stream := w.(http3.HTTPStreamer).HTTPStream() + defer stream.Close() + data, err := stream.ReceiveDatagram(r.Context()) + if err != nil || len(data) == 0 || data[0] != 0 { + return + } + if _, err = conn.Write(data[1:]); err != nil { + return + } + buffer := make([]byte, 2048) + n, err := conn.Read(buffer) + if err != nil { + return + } + if err := stream.SendDatagram(append([]byte{0}, buffer[:n]...)); err != nil { + return + } + <-r.Context().Done() + })) + cfg := fixture.config + cfg.AllowIPv6 = true + d := &masqueDialer{config: cfg, protector: &jumpTestProtector{}} + defer d.Close() + packet, err := d.DialUDP(&M.Metadata{DstIP: netip.MustParseAddr("::1"), DstPort: uint16(peer.Port)}) + if err != nil { + t.Fatal(err) + } + defer packet.Close() + packet.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := packet.WriteTo([]byte("IPv6"), peer); err != nil { + t.Fatal(err) + } + buffer := make([]byte, 64) + n, from, err := packet.ReadFrom(buffer) + if err != nil || string(buffer[:n]) != "origin:IPv6" || from.String() != peer.String() { + t.Fatalf("IPv6 origin response: %v", err) + } +} + func TestMASQUETrustAuthenticationAndCancellation(t *testing.T) { base := masqueTestServer(t) for _, failure := range []string{"password", "certificate"} { diff --git a/native/mobile/real_servers_test.go b/native/mobile/real_servers_test.go index 4fde74b9..9a2fb6a6 100644 --- a/native/mobile/real_servers_test.go +++ b/native/mobile/real_servers_test.go @@ -251,6 +251,15 @@ func TestRealProxyServers(t *testing.T) { if err != nil || response.StatusCode != 200 || !bytes.Equal(body, payload) { t.Fatalf("HTTP echo failed: %v", err) } + invalid := cfg + invalid.Password = "wrong-password" + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + conn, authErr := realServerDialer(t, invalid)(ctx, target) + cancel() + if authErr == nil { + conn.Close() + t.Fatal("invalid proxy credentials reached origin: possible direct bypass") + } if target6 { blocked := cfg blocked.AllowIPv6 = false From c8ff2cc698acd032bc387ae4519a92793cfcedcf Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 10 Nov 2026 18:11:02 +0300 Subject: [PATCH 3/5] Isolate IPv6 fixture traffic and synchronize device route readiness --- .../java/net/megaproxy487/DeviceTestBase.kt | 8 ++++++++ .../java/net/megaproxy487/ProfileEditorUiTest.kt | 2 +- native/integration/Dockerfile | 3 ++- scripts/android_device_tests.py | 14 ++++++++++---- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt index cef638eb..a5def914 100644 --- a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt +++ b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt @@ -99,6 +99,14 @@ 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(host: String = argument("originHost")) { val payload = "MegaProxy device ${System.nanoTime()}" diff --git a/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt b/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt index 33382f67..37ceeaf0 100644 --- a/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt +++ b/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt @@ -14,7 +14,7 @@ class ProfileEditorUiTest : MainUiTestBase() { val id = store.activeProfileId() content { ProfileEditorScreen(activity, id, {}) } for (type in ProxyType.entries) { - selectOption(R.string.profile_type, type.titleRes) + 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 diff --git a/native/integration/Dockerfile b/native/integration/Dockerfile index 82ee101d..3c4908ed 100644 --- a/native/integration/Dockerfile +++ b/native/integration/Dockerfile @@ -1,10 +1,11 @@ FROM alpine:3.23.3 +# Wrong-password cases must not impose timed bans on later independent scenarios. RUN apk add --no-cache openssh python3 openssl && \ adduser -D proxy && mkdir -p /home/proxy/.ssh /run/sshd && \ printf '%s\n' 'Port 2222' 'HostKey /etc/ssh/ssh_host_ed25519_key' \ 'PasswordAuthentication yes' 'PubkeyAuthentication yes' \ - 'KbdInteractiveAuthentication no' \ + 'KbdInteractiveAuthentication no' 'PerSourcePenalties no' \ 'PermitRootLogin no' 'AllowUsers proxy' 'AllowTcpForwarding yes' \ 'PermitTTY no' 'X11Forwarding no' > /etc/ssh/sshd_config COPY origin.py start-sshd.sh /fixture/ diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 37a9277b..16f82fdd 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -252,8 +252,14 @@ def start(alias, port, server_image, *args): "-j", "REJECT", ] - command("sudo", "-n", "iptables", "-I", *rule) - firewall.append(rule) + for tool, address in ( + ("iptables", origin_ip), + ("ip6tables", origin_ipv6), + ): + family_rule = list(rule) + family_rule[2] = address + command("sudo", "-n", tool, "-I", *family_rule) + firewall.append((tool, family_rule)) socks_auth = start( "socks-auth", "1080", @@ -385,9 +391,9 @@ def port(container, number): subprocess.run( ["docker", "rm", "-f", container], capture_output=True, timeout=30 ) - for rule in reversed(firewall): + for tool, rule in reversed(firewall): subprocess.run( - ["sudo", "-n", "iptables", "-D", *rule], + ["sudo", "-n", tool, "-D", *rule], capture_output=True, timeout=30, ) From bec3202228c9611fa674d91893cd7002679f430e Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 10 Nov 2026 18:17:11 +0300 Subject: [PATCH 4/5] Exercise IPv6 proxy and jump endpoints through Android bootstrap and JNI --- .../java/net/megaproxy487/VpnDeviceTest.kt | 23 +++++++++++++++---- docs/en/release-testing.md | 3 ++- docs/ru/release-testing.md | 3 ++- scripts/android_device_tests.py | 20 ++++++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index 1633c550..029e968a 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -25,7 +25,10 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class VpnDeviceTest : DeviceTestBase() { - @Test fun ipv6DestinationsThroughEveryProxyType() { + @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 { @@ -39,21 +42,33 @@ class VpnDeviceTest : DeviceTestBase() { 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 = when (type) { + host = if (ipv6Proxy) "[$proxyHost]" else when (type) { ProxyType.HTTPS_JUMP -> argument("tcpExitHost") ProxyType.SSH_JUMP -> argument("sshExitHost") else -> "10.0.2.2" }, - port = when (type) { + 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 = "10.0.2.2", jumpPort = port, + jumpHost = if (ipv6Proxy) proxyHost else "10.0.2.2", + jumpPort = if (ipv6Proxy) proxyPort else port, jumpTrustedHostKey = argument("sshFingerprint"), jumpAllowInvalidProxyCertificate = true, allowInvalidProxyCertificate = true, preferHttp3 = false, diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index af550f49..1c260131 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -14,7 +14,8 @@ bootstrap. Real GOST/OpenSSH tests vary IPv4/IPv6 proxy, jump and TCP destinatio 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, including SOCKS5 UDP and MASQUE UDP rejection. These use numeric addresses; +proxy types via TUN/JNI with both IPv4 and IPv6 proxy/jump endpoints, including SOCKS5 UDP and +MASQUE UDP rejection. These use numeric addresses; AAAA-only proxy hostname bootstrap remains unsupported by the A-only resolver. Physical-device IPv6 uplinks need a separate check. Adding tests is not evidence of passing runs; record CI results. diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index 8bfed443..ba72fe3c 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -14,7 +14,8 @@ bootstrap числовых адресов. Тесты с настоящими GO промежуточного узла и TCP-назначения; SOCKS5 UDP проверяет оба семейства назначения. Отказ GOST 3.3.0 для MASQUE IPv6 CONNECT-UDP ожидаем и не считается успешным обменом. Отдельный управляемый MASQUE-сервер пересылает датаграммы настоящему IPv6 UDP origin для проверки клиента. Тесты API 26/35 -проверяют IPv6-назначения через все шесть типов прокси и TUN/JNI, включая SOCKS5 UDP и отказ +проверяют IPv6-назначения через все шесть типов прокси и TUN/JNI с IPv4- и IPv6-адресами прокси +и промежуточных узлов, включая SOCKS5 UDP и отказ MASQUE UDP. Используются числовые адреса; bootstrap имён только с AAAA пока не поддерживается резолвером, запрашивающим A. IPv6 uplink физического устройства требует отдельной проверки. Наличие тестов не доказывает успешный прогон: результаты CI нужно фиксировать отдельно. diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 16f82fdd..663bbb06 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -29,6 +29,7 @@ "DnsDeviceTest#resolverModesThroughRealTun", "VpnDeviceTest#trafficStopAndRestart", "VpnDeviceTest#ipv6DestinationsThroughEveryProxyType", + "VpnDeviceTest#ipv6ProxiesAndDestinationsThroughEveryProxyType", "VpnDeviceTest#socks5AuthenticatedTcpUdpStopAndRestart", "VpnDeviceTest#socks5AnonymousTcpUdpStopAndRestart", "VpnDeviceTest#masqueTrafficStopAndRestart", @@ -343,7 +344,26 @@ def port(container, number): "-lf", "/etc/ssh/ssh_host_ed25519_key.pub", ).split()[1] + ipv6_proxies = { + transport + + "Ipv6": command( + "docker", + "inspect", + "--format", + "{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}", + container, + ) + for transport, container in ( + ("https", https), + ("socks5", socks_auth), + ("masque", masque), + ("ssh", ssh), + ) + } + if not all(ipv6_proxies.values()): + raise RuntimeError("IPv6 proxy address is missing") yield { + **ipv6_proxies, "originHost": origin_ip, "originIpv6": origin_ipv6, "directDnsPort": str(direct_dns.server_address[1]), From 20fa927486c0138a29d8654b2b0966482ccf19d9 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 10 Nov 2026 18:32:46 +0300 Subject: [PATCH 5/5] Fall back to AAAA bootstrap only after valid responses without A --- docs/en/dns.md | 4 ++ docs/en/release-testing.md | 4 +- docs/ru/dns.md | 5 ++ docs/ru/release-testing.md | 4 +- native/mobile/bootstrap.go | 62 ++++++++++++++----- native/mobile/bootstrap_test.go | 100 +++++++++++++++++++++++++++++++ native/mobile/robustness_test.go | 3 +- 7 files changed, 162 insertions(+), 20 deletions(-) diff --git a/docs/en/dns.md b/docs/en/dns.md index 3775b62a..18668b09 100644 --- a/docs/en/dns.md +++ b/docs/en/dns.md @@ -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 diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 1c260131..e626163b 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -15,8 +15,8 @@ SOCKS5 UDP covers both destination families. GOST 3.3.0 MASQUE IPv6 CONNECT-UDP 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. These use numeric addresses; -AAAA-only proxy hostname bootstrap remains unsupported by the A-only resolver. Physical-device +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 | diff --git a/docs/ru/dns.md b/docs/ru/dns.md index f42b467a..0c8265e5 100644 --- a/docs/ru/dns.md +++ b/docs/ru/dns.md @@ -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-соединением. Потоки diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index ba72fe3c..53cee898 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -16,8 +16,8 @@ bootstrap числовых адресов. Тесты с настоящими GO MASQUE-сервер пересылает датаграммы настоящему IPv6 UDP origin для проверки клиента. Тесты API 26/35 проверяют IPv6-назначения через все шесть типов прокси и TUN/JNI с IPv4- и IPv6-адресами прокси и промежуточных узлов, включая SOCKS5 UDP и отказ -MASQUE UDP. Используются числовые адреса; bootstrap имён только с AAAA пока не поддерживается -резолвером, запрашивающим A. IPv6 uplink физического устройства требует отдельной проверки. +MASQUE UDP. Bootstrap-тесты проверяют приоритет A, фолбек на AAAA, ошибки и повреждённые ответы, +а также использование разрешённого IPv6-адреса. IPv6 uplink физического устройства требует отдельной проверки. Наличие тестов не доказывает успешный прогон: результаты CI нужно фиксировать отдельно. | Изменение | Проверка | diff --git a/native/mobile/bootstrap.go b/native/mobile/bootstrap.go index 39b55c20..560e8e74 100644 --- a/native/mobile/bootstrap.go +++ b/native/mobile/bootstrap.go @@ -21,6 +21,8 @@ type bootstrapResolver struct { url string } +var errBootstrapNoAddress = errors.New("bootstrap DNS response contains no requested address") + // Each resolver is dialled by a documented IP address, while TLS still verifies // the provider hostname. Multiple networks block individual public resolvers, so // bootstrap must not have a single point of failure. @@ -46,19 +48,33 @@ func ResolveProxy(host string, protector Protector, reporter Reporter) (string, return "", err } type answer struct{ ip, provider string } - result, err := raceFirst(context.Background(), len(bootstrapResolvers), func(ctx context.Context, index int) (answer, error) { - resolver := bootstrapResolvers[index] - ip, err := resolveProxyWithResolver(ctx, query, id, resolver, protector) - if err != nil { - err = fmt.Errorf("%s: %w", resolver.name, err) + for _, recordType := range []uint16{1, 28} { + requestQuery := append([]byte(nil), query...) + binary.BigEndian.PutUint16(requestQuery[len(requestQuery)-4:], recordType) + result, resolveErr := raceFirst(context.Background(), len(bootstrapResolvers), func(ctx context.Context, index int) (answer, error) { + resolver := bootstrapResolvers[index] + ip, err := resolveProxyWithResolver(ctx, requestQuery, id, resolver, protector) + if err != nil { + err = fmt.Errorf("%s: %w", resolver.name, err) + } + return answer{ip, resolver.name}, err + }, nil) + if resolveErr == nil { + family := "ipv4" + if recordType == 28 { + family = "ipv6" + } + report(reporter, "event=bootstrap_dns stage=response result=success family=%s provider=%s", family, result.provider) + return result.ip, nil } - return answer{ip, resolver.name}, err - }, nil) - if err != nil { - return "", fmt.Errorf("all bootstrap DoH resolvers failed: %w", err) + err = resolveErr + // Prefer any available A answer; only a valid NODATA response permits AAAA fallback. + if recordType == 1 && errors.Is(err, errBootstrapNoAddress) { + continue + } + break } - report(reporter, "event=bootstrap_dns stage=response result=success family=ipv4 provider=%s", result.provider) - return result.ip, nil + return "", fmt.Errorf("all bootstrap DoH resolvers failed: %w", err) } func resolveProxyWithResolver(ctx context.Context, query []byte, id uint16, resolver bootstrapResolver, protector Protector) (string, error) { @@ -104,7 +120,7 @@ func resolveProxyWithResolver(ctx context.Context, query []byte, id uint16, reso if err != nil { return "", err } - ip, err := parseAResponse(payload, id) + ip, err := parseAddressResponse(payload, id, binary.BigEndian.Uint16(query[len(query)-4:])) if err != nil { return "", err } @@ -135,14 +151,20 @@ func buildAQuery(host string) ([]byte, uint16, error) { return message, id, nil } -func parseAResponse(message []byte, id uint16) (string, error) { +func parseAddressResponse(message []byte, id, requestedType uint16) (string, error) { if len(message) < 12 || binary.BigEndian.Uint16(message[0:2]) != id { return "", errors.New("invalid bootstrap DNS response") } if binary.BigEndian.Uint16(message[2:4])&0x000f != 0 { return "", errors.New("bootstrap DNS returned an error") } + if binary.BigEndian.Uint16(message[2:4])&0x8200 != 0x8000 { + return "", errors.New("invalid or truncated bootstrap DNS response") + } questions := int(binary.BigEndian.Uint16(message[4:6])) + if questions != 1 { + return "", errors.New("invalid bootstrap DNS question count") + } answers := int(binary.BigEndian.Uint16(message[6:8])) offset := 12 for range questions { @@ -151,6 +173,9 @@ func parseAResponse(message []byte, id uint16) (string, error) { if err != nil || offset+4 > len(message) { return "", errors.New("malformed bootstrap DNS question") } + if binary.BigEndian.Uint16(message[offset:]) != requestedType || binary.BigEndian.Uint16(message[offset+2:]) != 1 { + return "", errors.New("unexpected bootstrap DNS question") + } offset += 4 } for range answers { @@ -166,12 +191,19 @@ func parseAResponse(message []byte, id uint16) (string, error) { if offset+length > len(message) { return "", errors.New("truncated bootstrap DNS answer") } - if recordType == 1 && recordClass == 1 && length == net.IPv4len { + if recordType == requestedType && recordClass == 1 { + expectedLength := net.IPv4len + if requestedType == 28 { + expectedLength = net.IPv6len + } + if length != expectedLength { + return "", errors.New("invalid bootstrap DNS address length") + } return net.IP(message[offset : offset+length]).String(), nil } offset += length } - return "", errors.New("bootstrap DNS response contains no IPv4 address") + return "", errBootstrapNoAddress } func skipDNSName(message []byte, offset int) (int, error) { diff --git a/native/mobile/bootstrap_test.go b/native/mobile/bootstrap_test.go index 93eab15d..9a05277a 100644 --- a/native/mobile/bootstrap_test.go +++ b/native/mobile/bootstrap_test.go @@ -7,10 +7,100 @@ import ( "net/http" "net/http/httptest" "net/url" + "slices" + "strings" + "sync" "testing" "time" ) +func TestBootstrapAAAAFallback(t *testing.T) { + for _, mode := range []string{"dual_stack", "aaaa_only", "empty", "servfail", "http_error", "truncated", "wrong_id", "bad_aaaa_length"} { + t.Run(mode, func(t *testing.T) { + var mu sync.Mutex + var types []uint16 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query, _ := io.ReadAll(r.Body) + kind := binary.BigEndian.Uint16(query[len(query)-4:]) + mu.Lock() + types = append(types, kind) + mu.Unlock() + query[2] |= 0x80 + switch mode { + case "http_error": + w.WriteHeader(503) + return + case "servfail": + query[3] = 2 + case "truncated": + query[2] |= 2 + case "wrong_id": + query[0] ^= 1 + } + var address net.IP + if mode == "dual_stack" && kind == 1 { + address = net.IPv4(203, 0, 113, 7).To4() + } + if (mode == "aaaa_only" || mode == "bad_aaaa_length") && kind == 28 { + address = net.ParseIP("::1").To16() + } + if mode == "bad_aaaa_length" && kind == 28 { + address = address[:4] + } + if address != nil { + binary.BigEndian.PutUint16(query[6:8], 1) + query = append(query, 0xc0, 0x0c, 0, byte(kind), 0, 1, 0, 0, 0, 60, 0, byte(len(address))) + query = append(query, address...) + } + w.Write(query) + })) + defer server.Close() + saved := bootstrapResolvers + bootstrapResolvers = []bootstrapResolver{{name: "fixture", address: server.Listener.Addr().String(), url: server.URL}} + defer func() { bootstrapResolvers = saved }() + logs := &diagnosticRecorder{} + ip, err := ResolveProxy("proxy.test", &jumpTestProtector{}, logs) + success := mode == "dual_stack" || mode == "aaaa_only" + if (err == nil) != success { + t.Fatalf("result %q: %v", ip, err) + } + mu.Lock() + gotTypes := append([]uint16(nil), types...) + mu.Unlock() + fallback := mode == "aaaa_only" || mode == "empty" || mode == "bad_aaaa_length" + wantTypes := []uint16{1} + if fallback { + wantTypes = append(wantTypes, 28) + } + if !slices.Equal(gotTypes, wantTypes) { + t.Fatalf("query types: %v", gotTypes) + } + if mode == "dual_stack" && ip != "203.0.113.7" { + t.Fatal("IPv4 preference lost") + } + if mode == "aaaa_only" { + if !strings.Contains(logs.text(), "family=ipv6") { + t.Fatal("missing IPv6 bootstrap diagnostic") + } + if ip != "::1" { + t.Fatalf("AAAA result %q", ip) + } + listener, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + cfg := config{DialHost: ip, Port: listener.Addr().(*net.TCPAddr).Port} + conn, err := net.DialTimeout("tcp", cfg.address(), time.Second) + if err != nil { + t.Fatal(err) + } + conn.Close() + } + }) + } +} + func TestBootstrapIPv6LiteralDoesNotUseDNS(t *testing.T) { for _, host := range []string{"2001:db8::1", "[2001:db8::1]"} { got, err := ResolveProxy(host, nil, nil) @@ -58,6 +148,15 @@ func TestResolveProxyLiteralDoesNotRequireDNS(t *testing.T) { } func TestBootstrapFastResolverWinsAndCancelsSlowRequest(t *testing.T) { + empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query, _ := io.ReadAll(r.Body) + if binary.BigEndian.Uint16(query[len(query)-4:]) != 1 { + t.Error("AAAA requested before checking other A resolvers") + } + query[2] |= 0x80 + w.Write(query) + })) + defer empty.Close() cancelled := make(chan struct{}) slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.Copy(io.Discard, r.Body) @@ -75,6 +174,7 @@ func TestBootstrapFastResolverWinsAndCancelsSlowRequest(t *testing.T) { defer fast.Close() saved := bootstrapResolvers bootstrapResolvers = []bootstrapResolver{ + {name: "empty", address: empty.Listener.Addr().String(), url: empty.URL}, {name: "slow", address: slow.Listener.Addr().String(), url: slow.URL}, {name: "fast", address: fast.Listener.Addr().String(), url: fast.URL}, } diff --git a/native/mobile/robustness_test.go b/native/mobile/robustness_test.go index 9ddc3fb2..762b8a97 100644 --- a/native/mobile/robustness_test.go +++ b/native/mobile/robustness_test.go @@ -155,7 +155,8 @@ func FuzzNativeParsers(f *testing.F) { _, _ = parseConfig(string(data)) _, _ = parseJA3(string(data)) _, _, _ = buildAQuery(string(data)) - _, _ = parseAResponse(data, 1) + _, _ = parseAddressResponse(data, 1, 1) + _, _ = parseAddressResponse(data, 1, 28) _, _, _ = emptyAAAAResponse(data) _, _ = skipDNSName(data, -1) _, _ = skipDNSName(data, 0)