Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <b64>`.
*
* 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<String, String>?): Pair<Int, String> {
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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port> are reachable as 10.0.2.2:<port>.
*
* 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
*/
Expand All @@ -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()
Expand All @@ -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()
)
}

Expand All @@ -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)
Expand All @@ -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())
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/org/proxydroid/ProxyDroidVpnService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading