diff --git a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt index a26ad21f..a5def914 100644 --- a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt +++ b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt @@ -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() @@ -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. diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index 870c03ae..029e968a 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -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) 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..37ceeaf0 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) { + 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) 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/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 0fbd2a25..e626163b 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -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 | 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 ca1f05de..53cee898 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -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 | 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/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..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. @@ -34,7 +36,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 } @@ -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 2004b7d0..9a05277a 100644 --- a/native/mobile/bootstrap_test.go +++ b/native/mobile/bootstrap_test.go @@ -7,10 +7,109 @@ 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) + 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)) @@ -49,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) @@ -66,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/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/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 4d2d8834..9a2fb6a6 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,129 @@ 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) + } + 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 + 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/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) diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index e80adc52..663bbb06 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -28,6 +28,8 @@ "VpnDeviceTest#deniedVpnConsentDoesNotStartTunnel", "DnsDeviceTest#resolverModesThroughRealTun", "VpnDeviceTest#trafficStopAndRestart", + "VpnDeviceTest#ipv6DestinationsThroughEveryProxyType", + "VpnDeviceTest#ipv6ProxiesAndDestinationsThroughEveryProxyType", "VpnDeviceTest#socks5AuthenticatedTcpUdpStopAndRestart", "VpnDeviceTest#socks5AnonymousTcpUdpStopAndRestart", "VpnDeviceTest#masqueTrafficStopAndRestart", @@ -193,7 +195,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 +225,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": @@ -242,8 +253,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", @@ -327,8 +344,28 @@ 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]), "proxyPort": https_port, "socksPort": port(socks_auth, "1080"), @@ -354,6 +391,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, @@ -367,9 +411,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, )