diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index e14707a9..d6493f27 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -2,9 +2,9 @@ name: Android Build on: push: - branches: [ master, main, 'claude/**' ] + branches: [ master, main, 'claude/**', 'feature/**', 'test/**' ] pull_request: - branches: [ master, main ] + branches: [ master, main, 'feature/**' ] jobs: build: diff --git a/app/src/androidTest/java/org/proxydroid/HostHttpConnectProxyIntegrationTest.kt b/app/src/androidTest/java/org/proxydroid/HostHttpConnectProxyIntegrationTest.kt new file mode 100644 index 00000000..e418b066 --- /dev/null +++ b/app/src/androidTest/java/org/proxydroid/HostHttpConnectProxyIntegrationTest.kt @@ -0,0 +1,125 @@ +package org.proxydroid + +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Socket + +/** + * Integration tests: from inside the Android emulator, drive raw HTTP CONNECT + * requests against host-side fake HTTP CONNECT proxies — both an auth-less + * variant and one requiring `Proxy-Authorization: Basic `. + * + * Overrides via instrumentation args, e.g.: + * ./gradlew connectedAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyHost=10.0.2.2 \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyPort=8081 \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthPort=8082 \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthUser=alice \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthPass=s3cret \ + * -Pandroid.testInstrumentationRunnerArguments.httpsTargetHost=example.com \ + * -Pandroid.testInstrumentationRunnerArguments.httpsTargetPort=443 + */ +@RunWith(AndroidJUnit4::class) +class HostHttpConnectProxyIntegrationTest { + + private val args = InstrumentationRegistry.getArguments() + private val proxyHost: String = args.getString("httpProxyHost", "10.0.2.2") + private val proxyNoAuthPort: Int = args.getString("httpProxyPort", "8081").toInt() + private val proxyAuthPort: Int = args.getString("httpProxyAuthPort", "8082").toInt() + private val authUser: String = args.getString("httpProxyAuthUser", "alice") + private val authPass: String = args.getString("httpProxyAuthPass", "s3cret") + private val targetHost: String = args.getString("httpsTargetHost", "example.com") + private val targetPort: Int = args.getString("httpsTargetPort", "443").toInt() + private val connectTimeoutMs: Int = args.getString("connectTimeoutMs", "10000").toInt() + private val readTimeoutMs: Int = args.getString("readTimeoutMs", "15000").toInt() + + @Test + fun httpConnectThroughHostProxyNoAuth() { + val (code, _) = doConnect(proxyNoAuthPort, creds = null) + assertEquals("Expected 200 from auth-less CONNECT", 200, code) + } + + @Test + fun httpConnectBasicAuthSucceedsWithCorrectCredentials() { + val (code, _) = doConnect(proxyAuthPort, creds = authUser to authPass) + assertEquals("Expected 200 from auth'd CONNECT", 200, code) + } + + @Test + fun httpConnectBasicAuthRejectsWrongCredentials() { + val (code, _) = doConnect(proxyAuthPort, creds = "nobody" to "definitelywrong") + assertEquals( + "Expected 407 Proxy Authentication Required from wrong creds", + 407, + code, + ) + } + + @Test + fun httpConnectBasicAuthRejectsMissingCredentials() { + val (code, _) = doConnect(proxyAuthPort, creds = null) + assertEquals( + "Expected 407 Proxy Authentication Required from no creds", + 407, + code, + ) + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** Send a CONNECT, return (status_code, full_status_line). */ + private fun doConnect(port: Int, creds: Pair?): Pair { + Socket().use { socket -> + socket.connect(InetSocketAddress(proxyHost, port), connectTimeoutMs) + socket.soTimeout = readTimeoutMs + val out = DataOutputStream(socket.getOutputStream()) + val input = DataInputStream(socket.getInputStream()) + + val hostPort = "$targetHost:$targetPort" + val sb = StringBuilder() + .append("CONNECT ").append(hostPort).append(" HTTP/1.1\r\n") + .append("Host: ").append(hostPort).append("\r\n") + .append("Proxy-Connection: keep-alive\r\n") + if (creds != null) { + val raw = "${creds.first}:${creds.second}".toByteArray(Charsets.UTF_8) + val b64 = Base64.encodeToString(raw, Base64.NO_WRAP) + sb.append("Proxy-Authorization: Basic ").append(b64).append("\r\n") + } + sb.append("\r\n") + out.write(sb.toString().toByteArray(Charsets.US_ASCII)) + out.flush() + + val statusLine = readHttpStatusLine(input) + assertTrue( + "Expected HTTP/1.x status line, got: $statusLine", + statusLine.startsWith("HTTP/1."), + ) + val parts = statusLine.split(' ', limit = 3) + assertTrue("Malformed status line: $statusLine", parts.size >= 2) + val code = parts[1].toIntOrNull() ?: -1 + return code to statusLine + } + } + + private fun readHttpStatusLine(input: DataInputStream): String { + val buf = StringBuilder() + while (true) { + val b = try { input.read() } catch (_: IOException) { -1 } + if (b == -1) break + if (b == '\n'.code) break + if (b != '\r'.code) buf.append(b.toChar()) + } + return buf.toString() + } +} diff --git a/app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt b/app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt index 89ba0bcb..ccd03908 100644 --- a/app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt +++ b/app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt @@ -8,20 +8,25 @@ import org.junit.Test import org.junit.runner.RunWith import java.io.DataInputStream import java.io.DataOutputStream +import java.io.IOException import java.net.InetSocketAddress import java.net.Socket /** - * Integration test: from inside the Android emulator, route an HTTP request through - * a SOCKS5 proxy listening on the host machine at 0.0.0.0:1080. + * Integration tests: from inside the Android emulator, drive raw SOCKS5 + * handshakes against host-side fake SOCKS5 proxies (NO_AUTH and RFC 1929 + * user/password). * - * The emulator reaches the host loopback via the special alias 10.0.2.2, so a host - * proxy bound to 0.0.0.0:1080 is reachable as 10.0.2.2:1080 from the device. + * The emulator reaches the host loopback via the alias 10.0.2.2, so host + * proxies bound to 0.0.0.0: are reachable as 10.0.2.2:. * - * Override at runtime with instrumentation args, e.g.: + * Overrides via instrumentation args, e.g.: * ./gradlew connectedAndroidTest \ * -Pandroid.testInstrumentationRunnerArguments.socksHost=10.0.2.2 \ * -Pandroid.testInstrumentationRunnerArguments.socksPort=1080 \ + * -Pandroid.testInstrumentationRunnerArguments.socksAuthPort=1081 \ + * -Pandroid.testInstrumentationRunnerArguments.socksAuthUser=alice \ + * -Pandroid.testInstrumentationRunnerArguments.socksAuthPass=s3cret \ * -Pandroid.testInstrumentationRunnerArguments.targetHost=example.com \ * -Pandroid.testInstrumentationRunnerArguments.targetPort=80 */ @@ -31,6 +36,9 @@ class HostSocks5ProxyIntegrationTest { private val args = InstrumentationRegistry.getArguments() private val socksHost: String = args.getString("socksHost", "10.0.2.2") private val socksPort: Int = args.getString("socksPort", "1080").toInt() + private val socksAuthPort: Int = args.getString("socksAuthPort", "1081").toInt() + private val socksAuthUser: String = args.getString("socksAuthUser", "alice") + private val socksAuthPass: String = args.getString("socksAuthPass", "s3cret") private val targetHost: String = args.getString("targetHost", "example.com") private val targetPort: Int = args.getString("targetPort", "80").toInt() private val connectTimeoutMs: Int = args.getString("connectTimeoutMs", "10000").toInt() @@ -41,49 +49,107 @@ class HostSocks5ProxyIntegrationTest { Socket().use { socket -> socket.connect(InetSocketAddress(socksHost, socksPort), connectTimeoutMs) socket.soTimeout = readTimeoutMs + val out = DataOutputStream(socket.getOutputStream()) + val input = DataInputStream(socket.getInputStream()) + + socks5Greet(out, input, user = null, pass = null) + socks5ConnectByDomain(out, input, targetHost, targetPort) + assertHttpGetSucceeds(out, input) + } + } + @Test + fun httpGetThroughHostSocks5ProxyWithBasicAuth() { + Socket().use { socket -> + socket.connect(InetSocketAddress(socksHost, socksAuthPort), connectTimeoutMs) + socket.soTimeout = readTimeoutMs val out = DataOutputStream(socket.getOutputStream()) val input = DataInputStream(socket.getInputStream()) - socks5Greet(out, input) + socks5Greet(out, input, user = socksAuthUser, pass = socksAuthPass) socks5ConnectByDomain(out, input, targetHost, targetPort) + assertHttpGetSucceeds(out, input) + } + } - val request = buildString { - append("GET / HTTP/1.1\r\n") - append("Host: ").append(targetHost).append("\r\n") - append("User-Agent: ProxyDroid-IntegrationTest/1.0\r\n") - append("Accept: */*\r\n") - append("Connection: close\r\n\r\n") - }.toByteArray(Charsets.US_ASCII) - out.write(request) + @Test + fun socks5BasicAuthRejectsWrongCredentials() { + Socket().use { socket -> + socket.connect(InetSocketAddress(socksHost, socksAuthPort), connectTimeoutMs) + socket.soTimeout = readTimeoutMs + val out = DataOutputStream(socket.getOutputStream()) + val input = DataInputStream(socket.getInputStream()) + + // Offer user/pass auth and supply credentials that don't match. + out.write(byteArrayOf(0x05, 0x01, 0x02)) out.flush() + assertEquals("VER mismatch on greeting", 0x05, input.readUnsignedByte()) + assertEquals("Server should select USER/PASS method", 0x02, input.readUnsignedByte()) - val statusLine = readLine(input) - assertTrue( - "Expected HTTP/1.x status line, got: $statusLine", - statusLine.startsWith("HTTP/1.") - ) - val parts = statusLine.split(' ', limit = 3) - assertTrue("Malformed status line: $statusLine", parts.size >= 2) - val code = parts[1].toIntOrNull() ?: -1 + val badUser = "nobody" + val badPass = "definitelywrong" + out.write(byteArrayOf(0x01)) + out.write(byteArrayOf(badUser.length.toByte())) + out.write(badUser.toByteArray(Charsets.US_ASCII)) + out.write(byteArrayOf(badPass.length.toByte())) + out.write(badPass.toByteArray(Charsets.US_ASCII)) + out.flush() + + val subVer = input.readUnsignedByte() + val status = input.readUnsignedByte() + assertEquals("RFC 1929 sub-negotiation VER mismatch", 0x01, subVer) assertTrue( - "Expected 2xx/3xx through proxy, got: $statusLine", - code in 200..399 + "Expected non-zero auth status (rejection), got $status", + status != 0x00 ) } } - private fun socks5Greet(out: DataOutputStream, input: DataInputStream) { - // VER=5, NMETHODS=1, METHOD=0 (NO AUTH) - out.write(byteArrayOf(0x05, 0x01, 0x00)) + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private fun socks5Greet( + out: DataOutputStream, + input: DataInputStream, + user: String?, + pass: String?, + ) { + if (user == null) { + // VER=5, NMETHODS=1, METHOD=0 (NO AUTH) + out.write(byteArrayOf(0x05, 0x01, 0x00)) + out.flush() + assertEquals("SOCKS version mismatch", 0x05, input.readUnsignedByte()) + assertEquals( + "Proxy did not accept NO_AUTH (auth-less test variant)", + 0x00, + input.readUnsignedByte() + ) + return + } + + // Offer NO_AUTH and USER/PASS, then perform RFC 1929 sub-negotiation. + out.write(byteArrayOf(0x05, 0x02, 0x00, 0x02)) out.flush() - val ver = input.readUnsignedByte() + assertEquals("SOCKS version mismatch", 0x05, input.readUnsignedByte()) val method = input.readUnsignedByte() - assertEquals("SOCKS version mismatch", 0x05, ver) + assertEquals("Proxy did not select USER/PASS auth", 0x02, method) + + val u = user.toByteArray(Charsets.US_ASCII) + val p = (pass ?: "").toByteArray(Charsets.US_ASCII) + require(u.size in 1..255 && p.size in 0..255) { "creds out of range" } + out.write(byteArrayOf(0x01)) + out.write(byteArrayOf(u.size.toByte())) + out.write(u) + out.write(byteArrayOf(p.size.toByte())) + out.write(p) + out.flush() + + assertEquals("RFC 1929 sub-negotiation VER mismatch", 0x01, input.readUnsignedByte()) assertEquals( - "SOCKS proxy did not accept NO_AUTH (method=$method); test assumes unauthenticated proxy", + "RFC 1929 auth failed (status != 0)", 0x00, - method + input.readUnsignedByte() ) } @@ -95,7 +161,6 @@ class HostSocks5ProxyIntegrationTest { ) { val hostBytes = host.toByteArray(Charsets.US_ASCII) require(hostBytes.size <= 255) { "Hostname too long for SOCKS5: $host" } - // VER=5, CMD=1 CONNECT, RSV=0, ATYP=3 DOMAINNAME, LEN, HOST, PORT(BE) out.write(byteArrayOf(0x05, 0x01, 0x00, 0x03, hostBytes.size.toByte())) out.write(hostBytes) out.writeShort(port) @@ -108,23 +173,44 @@ class HostSocks5ProxyIntegrationTest { assertEquals("SOCKS reply version mismatch", 0x05, ver) assertEquals("SOCKS CONNECT failed with REP=$rep", 0x00, rep) - // Drain BND.ADDR + BND.PORT so the stream sits at the start of payload. when (atyp) { - 0x01 -> input.skipBytes(4) // IPv4 + 0x01 -> input.skipBytes(4) 0x03 -> { val len = input.readUnsignedByte() input.skipBytes(len) } - 0x04 -> input.skipBytes(16) // IPv6 + 0x04 -> input.skipBytes(16) else -> throw AssertionError("Unknown SOCKS ATYP=$atyp") } input.skipBytes(2) // BND.PORT } - private fun readLine(input: DataInputStream): String { + private fun assertHttpGetSucceeds(out: DataOutputStream, input: DataInputStream) { + val request = buildString { + append("GET / HTTP/1.1\r\n") + append("Host: ").append(targetHost).append("\r\n") + append("User-Agent: ProxyDroid-IntegrationTest/1.0\r\n") + append("Accept: */*\r\n") + append("Connection: close\r\n\r\n") + }.toByteArray(Charsets.US_ASCII) + out.write(request) + out.flush() + + val statusLine = readHttpStatusLine(input) + assertTrue( + "Expected HTTP/1.x status line, got: $statusLine", + statusLine.startsWith("HTTP/1.") + ) + val parts = statusLine.split(' ', limit = 3) + assertTrue("Malformed status line: $statusLine", parts.size >= 2) + val code = parts[1].toIntOrNull() ?: -1 + assertTrue("Expected 2xx/3xx, got: $statusLine", code in 200..399) + } + + private fun readHttpStatusLine(input: DataInputStream): String { val buf = StringBuilder() while (true) { - val b = input.read() + val b = try { input.read() } catch (_: IOException) { -1 } if (b == -1) break if (b == '\n'.code) break if (b != '\r'.code) buf.append(b.toChar()) diff --git a/app/src/main/java/org/proxydroid/ProxyDroidVpnService.kt b/app/src/main/java/org/proxydroid/ProxyDroidVpnService.kt index f754a30b..670a10d4 100644 --- a/app/src/main/java/org/proxydroid/ProxyDroidVpnService.kt +++ b/app/src/main/java/org/proxydroid/ProxyDroidVpnService.kt @@ -151,8 +151,8 @@ class ProxyDroidVpnService : VpnService() { .addRoute(VPN_ROUTE, 0) .addDnsServer("10.0.0.2") - // Always exclude our own UID so tun2socks / LocalProxyServer can reach - // the upstream SOCKS without the packets looping back into our own tun. + // Always exclude our own UID so tun2socks can reach the upstream + // proxy without the packets looping back into our own tun. try { builder.addDisallowedApplication(packageName) } catch (e: Exception) { diff --git a/app/src/main/java/org/proxydroid/utils/LocalProxyServer.kt b/app/src/main/java/org/proxydroid/utils/LocalProxyServer.kt deleted file mode 100644 index a5f560a9..00000000 --- a/app/src/main/java/org/proxydroid/utils/LocalProxyServer.kt +++ /dev/null @@ -1,187 +0,0 @@ -/* proxydroid - Global / Individual Proxy App for Android - * Copyright (C) 2011 Max Lv - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.proxydroid.utils - -import android.util.Log -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.net.InetAddress -import java.net.ServerSocket -import java.net.Socket -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -class LocalProxyServer( - private val localPort: Int, - private val remoteHost: String, - private val remotePort: Int, - private val username: String?, - private val password: String? -) : Runnable { - - private var serverSocket: ServerSocket? = null - private var running = false - private var executor: ExecutorService? = null - - companion object { - private const val TAG = "LocalProxyServer" - private const val BUFFER_SIZE = 8192 - } - - @Synchronized - fun start() { - if (running) return - running = true - executor = Executors.newCachedThreadPool() - Thread(this).start() - } - - @Synchronized - fun stop() { - running = false - try { - serverSocket?.close() - } catch (e: IOException) { - Log.e(TAG, "Error closing server socket", e) - } - executor?.shutdownNow() - executor = null - } - - override fun run() { - try { - serverSocket = ServerSocket(localPort, 50, InetAddress.getByName("127.0.0.1")) - Log.i(TAG, "Local SOCKS5 proxy server started on port $localPort") - - while (running) { - try { - val clientSocket = serverSocket?.accept() ?: break - executor?.execute(ClientHandler(clientSocket)) - } catch (e: IOException) { - if (running) { - Log.e(TAG, "Error accepting connection", e) - } - } - } - } catch (e: IOException) { - Log.e(TAG, "Error starting server", e) - } finally { - try { - serverSocket?.close() - } catch (e: IOException) { - // Ignore - } - } - } - - private inner class ClientHandler(private val clientSocket: Socket) : Runnable { - override fun run() { - var remoteSocket: Socket? = null - try { - val clientIn = clientSocket.getInputStream() - val clientOut = clientSocket.getOutputStream() - - // Connect to remote SOCKS5 proxy - remoteSocket = Socket(remoteHost, remotePort) - val remoteIn = remoteSocket.getInputStream() - val remoteOut = remoteSocket.getOutputStream() - - // Authenticate with remote proxy if needed - if (!authenticate(remoteIn, remoteOut)) { - Log.e(TAG, "Authentication failed") - return - } - - // Relay data between client and remote - val clientToRemote = Thread { relay(clientIn, remoteOut) } - val remoteToClient = Thread { relay(remoteIn, clientOut) } - - clientToRemote.start() - remoteToClient.start() - - clientToRemote.join() - remoteToClient.join() - - } catch (e: Exception) { - Log.e(TAG, "Error handling client", e) - } finally { - try { - clientSocket.close() - } catch (e: IOException) { - // Ignore - } - try { - remoteSocket?.close() - } catch (e: IOException) { - // Ignore - } - } - } - - private fun authenticate(input: InputStream, output: OutputStream): Boolean { - return try { - // SOCKS5 greeting - if (username != null && password != null) { - output.write(byteArrayOf(0x05, 0x01, 0x02)) // Version 5, 1 method, Username/Password - } else { - output.write(byteArrayOf(0x05, 0x01, 0x00)) // Version 5, 1 method, No auth - } - output.flush() - - val response = ByteArray(2) - if (input.read(response) != 2) return false - if (response[0] != 0x05.toByte()) return false - - if (response[1] == 0x02.toByte() && username != null && password != null) { - // Username/Password authentication - val authRequest = ByteArray(3 + username.length + password.length) - authRequest[0] = 0x01 - authRequest[1] = username.length.toByte() - System.arraycopy(username.toByteArray(), 0, authRequest, 2, username.length) - authRequest[2 + username.length] = password.length.toByte() - System.arraycopy(password.toByteArray(), 0, authRequest, 3 + username.length, password.length) - output.write(authRequest) - output.flush() - - val authResponse = ByteArray(2) - if (input.read(authResponse) != 2) return false - if (authResponse[1] != 0x00.toByte()) return false - } - - true - } catch (e: IOException) { - Log.e(TAG, "Authentication error", e) - false - } - } - - private fun relay(input: InputStream, output: OutputStream) { - try { - val buffer = ByteArray(BUFFER_SIZE) - var bytesRead: Int - while (input.read(buffer).also { bytesRead = it } != -1) { - output.write(buffer, 0, bytesRead) - output.flush() - } - } catch (e: IOException) { - // Connection closed - } - } - } -} diff --git a/app/src/main/rust/proxydroid-tun2socks/src/auth_e2e_tests.rs b/app/src/main/rust/proxydroid-tun2socks/src/auth_e2e_tests.rs new file mode 100644 index 00000000..869b412d --- /dev/null +++ b/app/src/main/rust/proxydroid-tun2socks/src/auth_e2e_tests.rs @@ -0,0 +1,310 @@ +//! End-to-end tests for upstream-proxy basic authentication. +//! +//! These tests spin up real tokio TCP listeners on 127.0.0.1 that act as +//! auth-requiring upstream proxies, then drive the crate's actual handshake +//! functions (`socks5_handshake`, `http_connect`) against them. They cover +//! both protocols across three cases each: correct credentials, +//! wrong credentials, and a credential-requiring upstream with no creds. + +use super::{http_connect, socks5_handshake, Target}; +use std::net::SocketAddr; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Bind a loopback listener on an ephemeral port and return (addr, listener). +async fn bind_loopback() -> (SocketAddr, TcpListener) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + (addr, listener) +} + +/// Read exactly `n` bytes or panic. +async fn read_exact_n(s: &mut TcpStream, n: usize) -> Vec { + let mut buf = vec![0u8; n]; + s.read_exact(&mut buf).await.unwrap(); + buf +} + +// --------------------------------------------------------------------------- +// Fake SOCKS5 upstream +// --------------------------------------------------------------------------- + +/// Behaviour of the fake SOCKS5 upstream. +#[derive(Clone, Copy)] +enum Socks5Mode { + /// Server advertises only user/password auth; accepts iff creds match. + RequireAuth { + user: &'static str, + pass: &'static str, + }, + /// Server requires auth and rejects everything (advertises 0xFF). + RejectNoMatchingMethod, +} + +/// Run a one-shot fake SOCKS5 server and return what the handshake wrote. +/// `ready_tx` fires once the listener is bound so the test can connect. +async fn run_fake_socks5(listener: TcpListener, mode: Socks5Mode) { + let (mut sock, _) = listener.accept().await.unwrap(); + + // Greeting: VER NMETHODS METHODS... + let header = read_exact_n(&mut sock, 2).await; + assert_eq!(header[0], 0x05, "bad SOCKS version"); + let nmethods = header[1] as usize; + let methods = read_exact_n(&mut sock, nmethods).await; + + match mode { + Socks5Mode::RejectNoMatchingMethod => { + // No acceptable methods. + sock.write_all(&[0x05, 0xFF]).await.unwrap(); + return; + } + Socks5Mode::RequireAuth { user, pass } => { + if !methods.contains(&0x02) { + sock.write_all(&[0x05, 0xFF]).await.unwrap(); + return; + } + sock.write_all(&[0x05, 0x02]).await.unwrap(); + + // RFC 1929 sub-negotiation. + let ver_ulen = read_exact_n(&mut sock, 2).await; + assert_eq!(ver_ulen[0], 0x01); + let ulen = ver_ulen[1] as usize; + let uname = read_exact_n(&mut sock, ulen).await; + let plen_buf = read_exact_n(&mut sock, 1).await; + let plen = plen_buf[0] as usize; + let pword = read_exact_n(&mut sock, plen).await; + + let creds_ok = uname == user.as_bytes() && pword == pass.as_bytes(); + if !creds_ok { + // RFC 1929: any non-zero status indicates failure. + sock.write_all(&[0x01, 0x01]).await.unwrap(); + return; + } + sock.write_all(&[0x01, 0x00]).await.unwrap(); + } + } + + // CONNECT request: VER CMD RSV ATYP ... + let head = read_exact_n(&mut sock, 4).await; + assert_eq!(head[0], 0x05); + assert_eq!(head[1], 0x01); // CONNECT + match head[3] { + 0x01 => { + let _ = read_exact_n(&mut sock, 6).await; // IPv4 + port + } + 0x03 => { + let l = read_exact_n(&mut sock, 1).await[0] as usize; + let _ = read_exact_n(&mut sock, l + 2).await; + } + 0x04 => { + let _ = read_exact_n(&mut sock, 18).await; + } + _ => panic!("bad ATYP"), + } + + // Success reply with a bogus BND.ADDR/BND.PORT. + sock.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + .await + .unwrap(); +} + +// --------------------------------------------------------------------------- +// Fake HTTP CONNECT upstream +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +struct HttpAuth { + user: &'static str, + pass: &'static str, +} + +/// Reads the request head until \r\n\r\n and returns it as a String. +async fn read_http_head(sock: &mut TcpStream) -> String { + let mut buf = Vec::with_capacity(512); + let mut byte = [0u8; 1]; + while buf.len() < 16 * 1024 { + sock.read_exact(&mut byte).await.unwrap(); + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") { + break; + } + } + String::from_utf8(buf).unwrap() +} + +/// Run a one-shot fake HTTP CONNECT server requiring the given Basic creds. +async fn run_fake_http_connect(listener: TcpListener, required: HttpAuth) { + use base64::Engine; + let (mut sock, _) = listener.accept().await.unwrap(); + let head = read_http_head(&mut sock).await; + + assert!( + head.starts_with("CONNECT "), + "expected CONNECT, got: {head:?}" + ); + + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", required.user, required.pass).as_bytes()) + ); + let presented = head + .lines() + .find_map(|l| l.strip_prefix("Proxy-Authorization: ")) + .map(|s| s.trim().to_string()); + + let response = match presented { + Some(v) if v == expected => { + "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test\r\n\r\n" + } + _ => "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"test\"\r\n\r\n", + }; + sock.write_all(response.as_bytes()).await.unwrap(); +} + +// --------------------------------------------------------------------------- +// Test target +// --------------------------------------------------------------------------- + +fn target_v4() -> Target { + Target::Ip(SocketAddr::from(([93, 184, 216, 34], 443))) +} + +// --------------------------------------------------------------------------- +// SOCKS5 tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn socks5_basic_auth_succeeds_with_correct_credentials() { + let (addr, listener) = bind_loopback().await; + let server = tokio::spawn(run_fake_socks5( + listener, + Socks5Mode::RequireAuth { + user: "alice", + pass: "s3cret", + }, + )); + + let client = TcpStream::connect(addr).await.unwrap(); + let target = target_v4(); + socks5_handshake(client, Some("alice"), Some("s3cret"), &target) + .await + .expect("handshake should succeed"); + + server.await.unwrap(); +} + +#[tokio::test] +async fn socks5_basic_auth_fails_with_wrong_password() { + let (addr, listener) = bind_loopback().await; + let server = tokio::spawn(run_fake_socks5( + listener, + Socks5Mode::RequireAuth { + user: "alice", + pass: "s3cret", + }, + )); + + let client = TcpStream::connect(addr).await.unwrap(); + let target = target_v4(); + let err = socks5_handshake(client, Some("alice"), Some("wrong"), &target) + .await + .expect_err("handshake should fail"); + assert!( + err.to_string().contains("SOCKS5 auth rejected"), + "unexpected error: {err}" + ); + + server.await.unwrap(); +} + +#[tokio::test] +async fn socks5_no_credentials_against_auth_required_upstream_fails() { + let (addr, listener) = bind_loopback().await; + let server = tokio::spawn(run_fake_socks5( + listener, + Socks5Mode::RejectNoMatchingMethod, + )); + + let client = TcpStream::connect(addr).await.unwrap(); + let target = target_v4(); + let err = socks5_handshake(client, None, None, &target) + .await + .expect_err("handshake should fail without creds"); + assert!( + err.to_string().contains("method not accepted"), + "unexpected error: {err}" + ); + + server.await.unwrap(); +} + +// --------------------------------------------------------------------------- +// HTTP CONNECT tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn http_connect_basic_auth_succeeds_with_correct_credentials() { + let (addr, listener) = bind_loopback().await; + let server = tokio::spawn(run_fake_http_connect( + listener, + HttpAuth { + user: "alice", + pass: "s3cret", + }, + )); + + let client = TcpStream::connect(addr).await.unwrap(); + let target = target_v4(); + http_connect(client, Some("alice"), Some("s3cret"), &target) + .await + .expect("CONNECT should return 200"); + + server.await.unwrap(); +} + +#[tokio::test] +async fn http_connect_basic_auth_fails_with_wrong_credentials() { + let (addr, listener) = bind_loopback().await; + let server = tokio::spawn(run_fake_http_connect( + listener, + HttpAuth { + user: "alice", + pass: "s3cret", + }, + )); + + let client = TcpStream::connect(addr).await.unwrap(); + let target = target_v4(); + let err = http_connect(client, Some("alice"), Some("wrong"), &target) + .await + .expect_err("CONNECT should fail"); + assert!(err.to_string().contains("407"), "unexpected error: {err}"); + + server.await.unwrap(); +} + +#[tokio::test] +async fn http_connect_no_credentials_against_auth_required_upstream_fails() { + let (addr, listener) = bind_loopback().await; + let server = tokio::spawn(run_fake_http_connect( + listener, + HttpAuth { + user: "alice", + pass: "s3cret", + }, + )); + + let client = TcpStream::connect(addr).await.unwrap(); + let target = target_v4(); + let err = http_connect(client, None, None, &target) + .await + .expect_err("CONNECT should fail without creds"); + assert!(err.to_string().contains("407"), "unexpected error: {err}"); + + server.await.unwrap(); +} diff --git a/app/src/main/rust/proxydroid-tun2socks/src/tun2socks.rs b/app/src/main/rust/proxydroid-tun2socks/src/tun2socks.rs index aa9900a9..1328931a 100644 --- a/app/src/main/rust/proxydroid-tun2socks/src/tun2socks.rs +++ b/app/src/main/rust/proxydroid-tun2socks/src/tun2socks.rs @@ -318,7 +318,8 @@ async fn connect_upstream(cfg: &UpstreamConfig, target: &Target) -> io::Result { let s = TcpStream::connect(&proxy_addr).await?; - let s = socks5_handshake(s, cfg.user.as_deref(), cfg.password.as_deref(), target).await?; + let s = + socks5_handshake(s, cfg.user.as_deref(), cfg.password.as_deref(), target).await?; Ok(Box::new(s)) } ProxyKind::Socks4 => { @@ -381,7 +382,12 @@ async fn socks5_handshake( return Err(io::Error::other(format!("SOCKS5 auth rejected: {}", ar[1]))); } } - m => return Err(io::Error::other(format!("SOCKS5 method not accepted: {}", m))), + m => { + return Err(io::Error::other(format!( + "SOCKS5 method not accepted: {}", + m + ))) + } } match target { @@ -453,12 +459,12 @@ async fn socks4_handshake( match target { Target::Ip(SocketAddr::V4(v4)) => { let mut req = Vec::with_capacity(8 + userid.len() + 1); - req.push(0x04); // VN - req.push(0x01); // CD = CONNECT + req.push(0x04); // VN + req.push(0x01); // CD = CONNECT req.extend_from_slice(&v4.port().to_be_bytes()); req.extend_from_slice(&v4.ip().octets()); req.extend_from_slice(userid); - req.push(0x00); // userid NUL terminator + req.push(0x00); // userid NUL terminator s.write_all(&req).await?; } Target::Ip(SocketAddr::V6(_)) => { @@ -470,7 +476,7 @@ async fn socks4_handshake( req.push(0x04); req.push(0x01); req.extend_from_slice(&port.to_be_bytes()); - req.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 = SOCKS4A marker + req.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 = SOCKS4A marker req.extend_from_slice(userid); req.push(0x00); req.extend_from_slice(domain.as_bytes()); @@ -482,7 +488,10 @@ async fn socks4_handshake( let mut resp = [0u8; 8]; s.read_exact(&mut resp).await?; if resp[0] != 0x00 { - return Err(io::Error::other(format!("SOCKS4 bad reply VN: {}", resp[0]))); + return Err(io::Error::other(format!( + "SOCKS4 bad reply VN: {}", + resp[0] + ))); } if resp[1] != 0x5A { return Err(io::Error::other(format!( @@ -712,3 +721,7 @@ async fn handle_dns_query( )); } } + +#[cfg(test)] +#[path = "auth_e2e_tests.rs"] +mod auth_e2e_tests; diff --git a/app/src/test/java/org/proxydroid/utils/LocalHttpProxyTest.kt b/app/src/test/java/org/proxydroid/utils/LocalHttpProxyTest.kt deleted file mode 100644 index ab2eaf32..00000000 --- a/app/src/test/java/org/proxydroid/utils/LocalHttpProxyTest.kt +++ /dev/null @@ -1,296 +0,0 @@ -/* proxydroid - Global / Individual Proxy App for Android - * Copyright (C) 2011 Max Lv - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - */ - -package org.proxydroid.utils - -import org.junit.Test -import org.junit.Assert.* -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.nio.charset.StandardCharsets -import java.util.Base64 - -class LocalHttpProxyTest { - - @Test - fun testSocks5HandshakeNoAuth() { - val clientHello = byteArrayOf(0x05, 0x01, 0x00) - val expectedResponse = byteArrayOf(0x05, 0x00) - - val input = ByteArrayInputStream(clientHello) - val output = ByteArrayOutputStream() - - val version = input.read() - val nmethods = input.read() - val methods = ByteArray(nmethods) - input.read(methods) - - assertEquals(0x05, version) - assertEquals(1, nmethods) - assertEquals(0x00.toByte(), methods[0]) - - output.write(expectedResponse) - - val response = output.toByteArray() - assertArrayEquals(expectedResponse, response) - } - - @Test - fun testSocks5HandshakeWithAuth() { - val clientHello = byteArrayOf(0x05, 0x02, 0x00, 0x02) - - val input = ByteArrayInputStream(clientHello) - - val version = input.read() - val nmethods = input.read() - val methods = ByteArray(nmethods) - input.read(methods) - - assertEquals(0x05, version) - assertEquals(2, nmethods) - assertEquals(0x00.toByte(), methods[0]) - assertEquals(0x02.toByte(), methods[1]) - } - - @Test - fun testSocks5ConnectRequestIPv4() { - val connectRequest = byteArrayOf( - 0x05, - 0x01, - 0x00, - 0x01, - 192.toByte(), 168.toByte(), 0x01, 0x01, - 0x00, 0x50 - ) - - val input = ByteArrayInputStream(connectRequest) - - val version = input.read() - val cmd = input.read() - val rsv = input.read() - val atyp = input.read() - - assertEquals(0x05, version) - assertEquals(0x01, cmd) - assertEquals(0x00, rsv) - assertEquals(0x01, atyp) - - val ipv4 = ByteArray(4) - input.read(ipv4) - assertEquals(192.toByte(), ipv4[0]) - assertEquals(168.toByte(), ipv4[1]) - assertEquals(0x01.toByte(), ipv4[2]) - assertEquals(0x01.toByte(), ipv4[3]) - - val port = ByteArray(2) - input.read(port) - val portNum = ((port[0].toInt() and 0xFF) shl 8) or (port[1].toInt() and 0xFF) - assertEquals(80, portNum) - } - - @Test - fun testSocks5ConnectRequestDomain() { - val domain = "example.com" - val domainBytes = domain.toByteArray(StandardCharsets.UTF_8) - - val request = ByteArrayOutputStream().apply { - write(0x05) - write(0x01) - write(0x00) - write(0x03) - write(domainBytes.size) - write(domainBytes) - write(0x01) - write(0xBB) - } - - val connectRequest = request.toByteArray() - val input = ByteArrayInputStream(connectRequest) - - val version = input.read() - val cmd = input.read() - val rsv = input.read() - val atyp = input.read() - - assertEquals(0x05, version) - assertEquals(0x01, cmd) - assertEquals(0x00, rsv) - assertEquals(0x03, atyp) - - val domainLen = input.read() - assertEquals(domain.length, domainLen) - - val readDomain = ByteArray(domainLen) - input.read(readDomain) - assertEquals(domain, String(readDomain, StandardCharsets.UTF_8)) - - val port = ByteArray(2) - input.read(port) - val portNum = ((port[0].toInt() and 0xFF) shl 8) or (port[1].toInt() and 0xFF) - assertEquals(443, portNum) - } - - @Test - fun testSocks5SuccessResponse() { - val successResponse = byteArrayOf( - 0x05, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00 - ) - - assertEquals(10, successResponse.size) - assertEquals(0x05.toByte(), successResponse[0]) - assertEquals(0x00.toByte(), successResponse[1]) - assertEquals(0x01.toByte(), successResponse[3]) - } - - @Test - fun testSocks5ErrorResponse() { - val errorCodes = intArrayOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08) - - for (errorCode in errorCodes) { - val errorResponse = byteArrayOf( - 0x05, errorCode.toByte(), 0x00, 0x01, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 - ) - - assertEquals(0x05.toByte(), errorResponse[0]) - assertEquals(errorCode, errorResponse[1].toInt() and 0xFF) - } - } - - @Test - fun testSocks5AuthRequest() { - val username = "testuser" - val password = "testpass" - - val userBytes = username.toByteArray(StandardCharsets.UTF_8) - val passBytes = password.toByteArray(StandardCharsets.UTF_8) - - val authRequest = ByteArrayOutputStream().apply { - write(0x01) - write(userBytes.size) - write(userBytes) - write(passBytes.size) - write(passBytes) - } - - val request = authRequest.toByteArray() - val input = ByteArrayInputStream(request) - - val authVersion = input.read() - assertEquals(0x01, authVersion) - - val userLen = input.read() - assertEquals(username.length, userLen) - - val readUser = ByteArray(userLen) - input.read(readUser) - assertEquals(username, String(readUser, StandardCharsets.UTF_8)) - - val passLen = input.read() - assertEquals(password.length, passLen) - - val readPass = ByteArray(passLen) - input.read(readPass) - assertEquals(password, String(readPass, StandardCharsets.UTF_8)) - } - - @Test - fun testHttpConnectRequest() { - val host = "example.com" - val port = 443 - val username = "user" - val password = "pass" - - val request = StringBuilder().apply { - append("CONNECT ").append(host).append(":").append(port).append(" HTTP/1.1\r\n") - append("Host: ").append(host).append(":").append(port).append("\r\n") - val auth = "$username:$password" - val encoded = Base64.getEncoder().encodeToString(auth.toByteArray(StandardCharsets.UTF_8)) - append("Proxy-Authorization: Basic ").append(encoded).append("\r\n") - append("\r\n") - } - - val requestStr = request.toString() - - assertTrue(requestStr.startsWith("CONNECT example.com:443 HTTP/1.1\r\n")) - assertTrue(requestStr.contains("Host: example.com:443\r\n")) - assertTrue(requestStr.contains("Proxy-Authorization: Basic ")) - assertTrue(requestStr.endsWith("\r\n\r\n")) - } - - @Test - fun testHttpConnectResponse() { - val successResponse = "HTTP/1.1 200 Connection Established\r\n\r\n" - val failResponse = "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n" - - assertTrue(successResponse.contains("200")) - assertFalse(failResponse.contains("200")) - } - - @Test - fun testIPv4Parsing() { - val ipBytes = byteArrayOf(192.toByte(), 168.toByte(), 0x01, 0x01) - - val ip = String.format( - "%d.%d.%d.%d", - ipBytes[0].toInt() and 0xFF, - ipBytes[1].toInt() and 0xFF, - ipBytes[2].toInt() and 0xFF, - ipBytes[3].toInt() and 0xFF - ) - - assertEquals("192.168.1.1", ip) - } - - @Test - fun testIPv6Parsing() { - val ipv6 = ByteArray(16) - ipv6[0] = 0x20 - ipv6[1] = 0x01 - ipv6[2] = 0x0d - ipv6[3] = 0xb8.toByte() - - val sb = StringBuilder() - for (i in 0 until 16 step 2) { - if (i > 0) sb.append(":") - sb.append(String.format("%02x%02x", ipv6[i].toInt() and 0xFF, ipv6[i + 1].toInt() and 0xFF)) - } - - val ip = sb.toString() - assertTrue(ip.startsWith("2001:0db8:")) - } - - @Test - fun testPortParsing() { - val portBytes80 = byteArrayOf(0x00, 0x50) - val portBytes443 = byteArrayOf(0x01, 0xBB.toByte()) - val portBytes8080 = byteArrayOf(0x1F, 0x90.toByte()) - - val port80 = ((portBytes80[0].toInt() and 0xFF) shl 8) or (portBytes80[1].toInt() and 0xFF) - val port443 = ((portBytes443[0].toInt() and 0xFF) shl 8) or (portBytes443[1].toInt() and 0xFF) - val port8080 = ((portBytes8080[0].toInt() and 0xFF) shl 8) or (portBytes8080[1].toInt() and 0xFF) - - assertEquals(80, port80) - assertEquals(443, port443) - assertEquals(8080, port8080) - } - - @Test - fun testBase64Encoding() { - val credentials = "user:password" - val encoded = Base64.getEncoder().encodeToString(credentials.toByteArray(StandardCharsets.UTF_8)) - - assertEquals("dXNlcjpwYXNzd29yZA==", encoded) - - val decoded = String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8) - assertEquals(credentials, decoded) - } -} diff --git a/scripts/run_emulator_tests.sh b/scripts/run_emulator_tests.sh index a8184c58..183608c7 100755 --- a/scripts/run_emulator_tests.sh +++ b/scripts/run_emulator_tests.sh @@ -1,42 +1,86 @@ #!/bin/bash -# Emulator integration tests for ProxyDroid VPN mode -# This script is run inside the Android emulator environment +# Host-side driver for emulator-based instrumentation tests. +# +# Spins up four Python fake-upstream proxies on the host: +# +# :1080 SOCKS5, no auth +# :1081 SOCKS5, user=$AUTH_USER / pass=$AUTH_PASS +# :8081 HTTP CONNECT, no auth +# :8082 HTTP CONNECT, user=$AUTH_USER / pass=$AUTH_PASS +# +# Then runs `connectedAndroidTest`, which from inside the emulator reaches the +# host loopback as 10.0.2.2 and exercises both protocols × {no-auth, auth-ok, +# auth-wrong-creds} via: +# +# HostSocks5ProxyIntegrationTest +# HostHttpConnectProxyIntegrationTest +# +# Invoked from the CI workflow (or locally with an already-running emulator). -set -e +set -euo pipefail -echo "=== Installing APK ===" -adb install -r app/build/outputs/apk/debug/app-debug.apk +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -echo "=== Verifying app installation ===" -adb shell pm list packages | grep org.proxydroid +AUTH_USER="${AUTH_USER:-alice}" +AUTH_PASS="${AUTH_PASS:-s3cret}" -echo "=== Starting app to create data directory ===" -adb shell am start -n org.proxydroid/.ProxyDroid -sleep 5 +PIDS=() +cleanup() { + set +e + for pid in "${PIDS[@]:-}"; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null + fi + done +} +trap cleanup EXIT -echo "=== Configuring proxy settings ===" -adb push /tmp/prefs.xml /data/local/tmp/prefs.xml +start_bg() { + local name="$1"; shift + echo "=== Starting $name: $* ===" + "$@" & + PIDS+=("$!") +} -echo "=== Checking VPN service declaration ===" -adb shell dumpsys package org.proxydroid | grep -A5 "Service" | head -20 +echo "=== Installing APK ===" +adb install -r "$PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk" -echo "=== Checking native libraries ===" -adb shell ls -la /data/app/*/org.proxydroid*/lib/*/ 2>/dev/null || echo "Could not list lib directory" +echo "=== Verifying app installation ===" +adb shell pm list packages | grep org.proxydroid -echo "=== Verifying app is running ===" -adb shell dumpsys activity activities | grep -A5 "org.proxydroid" | head -10 +echo "=== Booting fake upstream proxies on host ===" +start_bg "SOCKS5 no-auth :1080" \ + python3 "$SCRIPT_DIR/socks5_test_server.py" --port 1080 --quiet +start_bg "SOCKS5 user/pass :1081" \ + python3 "$SCRIPT_DIR/socks5_test_server.py" --port 1081 --auth "$AUTH_USER:$AUTH_PASS" --quiet +start_bg "HTTP CONNECT no-auth :8081" \ + python3 "$SCRIPT_DIR/http_connect_test_server.py" --port 8081 --quiet +start_bg "HTTP CONNECT user/pass :8082" \ + python3 "$SCRIPT_DIR/http_connect_test_server.py" --port 8082 --auth "$AUTH_USER:$AUTH_PASS" --quiet -echo "=== Test: App should handle proxy configuration ===" -adb shell am force-stop org.proxydroid -adb shell am start -n org.proxydroid/.ProxyDroid -sleep 3 +# Give listeners a moment to bind. +sleep 2 +for port in 1080 1081 8081 8082; do + if ! (echo > "/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + echo "FAIL: nothing listening on 127.0.0.1:$port" + exit 1 + fi +done +echo "=== All four proxies are listening ===" -echo "=== Final verification ===" -if adb shell pm list packages | grep -q "org.proxydroid"; then - echo "SUCCESS: App is installed and running" -else - echo "FAILURE: App installation verification failed" - exit 1 -fi +echo "=== Running connectedAndroidTest ===" +cd "$PROJECT_DIR" +./gradlew :app:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.socksHost=10.0.2.2 \ + -Pandroid.testInstrumentationRunnerArguments.socksPort=1080 \ + -Pandroid.testInstrumentationRunnerArguments.socksAuthPort=1081 \ + -Pandroid.testInstrumentationRunnerArguments.socksAuthUser="$AUTH_USER" \ + -Pandroid.testInstrumentationRunnerArguments.socksAuthPass="$AUTH_PASS" \ + -Pandroid.testInstrumentationRunnerArguments.httpProxyHost=10.0.2.2 \ + -Pandroid.testInstrumentationRunnerArguments.httpProxyPort=8081 \ + -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthPort=8082 \ + -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthUser="$AUTH_USER" \ + -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthPass="$AUTH_PASS" -echo "=== Emulator tests completed successfully ===" +echo "=== Emulator instrumentation tests passed ===" diff --git a/scripts/socks5_test_server.py b/scripts/socks5_test_server.py index 595ca3ce..2efb161e 100755 --- a/scripts/socks5_test_server.py +++ b/scripts/socks5_test_server.py @@ -8,13 +8,15 @@ 10.0.2.2:1080. Supports: - - SOCKS5 with NO_AUTH (method 0x00) only + - SOCKS5 with NO_AUTH (method 0x00) by default + - Optional RFC 1929 user/password auth (method 0x02) via --auth user:pass - CMD = CONNECT (0x01) only - ATYP = IPv4 (0x01), DOMAINNAME (0x03), IPv6 (0x04) Usage: python3 scripts/socks5_test_server.py python3 scripts/socks5_test_server.py --host 0.0.0.0 --port 1080 + python3 scripts/socks5_test_server.py --port 1081 --auth alice:s3cret """ from __future__ import annotations @@ -28,6 +30,8 @@ VER = 0x05 NO_AUTH = 0x00 +USER_PASS_AUTH = 0x02 +NO_ACCEPTABLE = 0xFF CMD_CONNECT = 0x01 ATYP_IPV4 = 0x01 ATYP_DOMAIN = 0x03 @@ -59,15 +63,39 @@ def send_reply(sock: socket.socket, rep: int) -> None: sock.sendall(struct.pack("!BBBB4sH", VER, rep, 0x00, ATYP_IPV4, b"\x00\x00\x00\x00", 0)) -def negotiate_auth(client: socket.socket) -> None: +def negotiate_auth(client: socket.socket, expect_auth: tuple[str, str] | None) -> None: ver, nmethods = struct.unpack("!BB", recv_exact(client, 2)) if ver != VER: raise ConnectionError(f"bad SOCKS version: {ver}") methods = recv_exact(client, nmethods) - if NO_AUTH not in methods: - client.sendall(struct.pack("!BB", VER, 0xFF)) - raise ConnectionError("client did not offer NO_AUTH") - client.sendall(struct.pack("!BB", VER, NO_AUTH)) + + if expect_auth is None: + if NO_AUTH not in methods: + client.sendall(struct.pack("!BB", VER, NO_ACCEPTABLE)) + raise ConnectionError("client did not offer NO_AUTH") + client.sendall(struct.pack("!BB", VER, NO_AUTH)) + return + + if USER_PASS_AUTH not in methods: + client.sendall(struct.pack("!BB", VER, NO_ACCEPTABLE)) + raise ConnectionError("client did not offer USER/PASS auth") + client.sendall(struct.pack("!BB", VER, USER_PASS_AUTH)) + + # RFC 1929: VER=1, ULEN, UNAME, PLEN, PASSWD + (sub_ver, ulen) = struct.unpack("!BB", recv_exact(client, 2)) + if sub_ver != 0x01: + raise ConnectionError(f"bad sub-negotiation version: {sub_ver}") + uname = recv_exact(client, ulen).decode("utf-8", errors="replace") + (plen,) = struct.unpack("!B", recv_exact(client, 1)) + passwd = recv_exact(client, plen).decode("utf-8", errors="replace") + + expected_user, expected_pass = expect_auth + if uname == expected_user and passwd == expected_pass: + client.sendall(struct.pack("!BB", 0x01, 0x00)) + else: + # Any non-zero status indicates failure. + client.sendall(struct.pack("!BB", 0x01, 0x01)) + raise ConnectionError(f"auth rejected for user={uname!r}") def read_request(client: socket.socket) -> tuple[str, int]: @@ -127,12 +155,16 @@ def relay(a: socket.socket, b: socket.socket) -> None: pass -def handle(client: socket.socket, addr: tuple[str, int]) -> None: +def handle( + client: socket.socket, + addr: tuple[str, int], + expect_auth: tuple[str, str] | None, +) -> None: log.info("client connected: %s:%s", *addr) remote: socket.socket | None = None try: client.settimeout(15) - negotiate_auth(client) + negotiate_auth(client, expect_auth) host, port = read_request(client) log.info("CONNECT %s:%s", host, port) try: @@ -158,15 +190,22 @@ def handle(client: socket.socket, addr: tuple[str, int]) -> None: pass -def serve(host: str, port: int) -> None: +def serve(host: str, port: int, expect_auth: tuple[str, str] | None) -> None: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((host, port)) srv.listen(64) - log.info("SOCKS5 proxy listening on %s:%d (NO_AUTH, CONNECT only)", host, port) + log.info( + "SOCKS5 proxy listening on %s:%d (auth=%s, CONNECT only)", + host, + port, + "user/pass" if expect_auth else "none", + ) while True: client, addr = srv.accept() - t = threading.Thread(target=handle, args=(client, addr), daemon=True) + t = threading.Thread( + target=handle, args=(client, addr, expect_auth), daemon=True + ) t.start() @@ -174,15 +213,26 @@ def main() -> None: p = argparse.ArgumentParser(description="Minimal SOCKS5 proxy for emulator integration tests") p.add_argument("--host", default="0.0.0.0") p.add_argument("--port", type=int, default=1080) + p.add_argument( + "--auth", + help="Require RFC 1929 user/password auth, value 'user:password'", + ) p.add_argument("--quiet", action="store_true") args = p.parse_args() + expect_auth: tuple[str, str] | None = None + if args.auth is not None: + if ":" not in args.auth: + p.error("--auth must be in the form user:password") + u, _, pw = args.auth.partition(":") + expect_auth = (u, pw) + logging.basicConfig( level=logging.WARNING if args.quiet else logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) try: - serve(args.host, args.port) + serve(args.host, args.port, expect_auth) except KeyboardInterrupt: pass