diff --git a/android-native/README.md b/android-native/README.md new file mode 100644 index 0000000..cd31710 --- /dev/null +++ b/android-native/README.md @@ -0,0 +1,34 @@ +# Android Native Slice + +This folder contains a separate Kotlin/Android rewrite scaffold for running a local proxy on Android. + +What works in this first slice: + +- Foreground service +- Local HTTP proxy listener +- Local SOCKS5 listener +- `CONNECT` tunneling +- Plain HTTP proxying with request-line rewrite +- JSON config editor in the app + +Current limits: + +- This is not yet a full port of the Python domain-fronting logic. +- No MITM CA handling on Android. +- No Apps Script relay or Google-fronted TLS transport yet. +- No UDP associate for SOCKS5. + +How to use: + +1. Open `android-native` in Android Studio. +2. Let Android Studio sync Gradle files. +3. Run the app on a device or emulator. +4. Edit the JSON config if needed. +5. Start the service and point your browser/app to `127.0.0.1:8085` or `127.0.0.1:1080`. + +Suggested next steps if you want this to become the real Android version: + +- Port the `domain_fronter.py` transport layer to Kotlin +- Add a real config model for `apps_script`, `domain_fronting`, and `google_fronting` +- Add log streaming in the UI +- Add optional `VpnService` mode for device-wide capture diff --git a/android-native/app/build.gradle.kts b/android-native/app/build.gradle.kts new file mode 100644 index 0000000..cf8aa6e --- /dev/null +++ b/android-native/app/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "ir.masteralidns.androidproxy" + compileSdk = 34 + + defaultConfig { + applicationId = "ir.masteralidns.androidproxy" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/android-native/app/proguard-rules.pro b/android-native/app/proguard-rules.pro new file mode 100644 index 0000000..a48c986 --- /dev/null +++ b/android-native/app/proguard-rules.pro @@ -0,0 +1 @@ +# Intentionally minimal for the first Android-native slice. diff --git a/android-native/app/src/main/AndroidManifest.xml b/android-native/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fff41b3 --- /dev/null +++ b/android-native/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android-native/app/src/main/java/ir/masteralidns/androidproxy/LocalProxyEngine.kt b/android-native/app/src/main/java/ir/masteralidns/androidproxy/LocalProxyEngine.kt new file mode 100644 index 0000000..7df3ba2 --- /dev/null +++ b/android-native/app/src/main/java/ir/masteralidns/androidproxy/LocalProxyEngine.kt @@ -0,0 +1,393 @@ +package ir.masteralidns.androidproxy + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.URI +import java.nio.charset.StandardCharsets +import java.util.Locale + +class LocalProxyEngine( + private val config: ProxyConfig, + private val log: (String) -> Unit, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val listeners = mutableListOf() + + fun start() { + if (config.listenPort == config.socks5Port && config.socks5Enabled) { + throw IllegalArgumentException("HTTP and SOCKS5 ports must be different.") + } + + listeners += bindListener(config.listenHost, config.listenPort) + scope.launch { acceptLoop(listeners[0], "HTTP", ::handleHttpClient) } + + if (config.socks5Enabled) { + val socksListener = bindListener(config.listenHost, config.socks5Port) + listeners += socksListener + scope.launch { acceptLoop(socksListener, "SOCKS5", ::handleSocks5Client) } + } + } + + fun stop() { + listeners.forEach(::closeQuietly) + listeners.clear() + scope.cancel() + } + + private fun bindListener(host: String, port: Int): ServerSocket { + val server = ServerSocket() + server.reuseAddress = true + server.bind(InetSocketAddress(InetAddress.getByName(host), port)) + return server + } + + private suspend fun acceptLoop( + server: ServerSocket, + label: String, + handler: suspend (Socket) -> Unit, + ) { + log("$label listener on ${server.inetAddress.hostAddress}:${server.localPort}") + while (scope.isActive && !server.isClosed) { + try { + val client = server.accept() + client.tcpNoDelay = true + scope.launch { handler(client) } + } catch (_: Exception) { + if (!server.isClosed) { + log("$label listener stopped unexpectedly") + } + return + } + } + } + + private suspend fun handleHttpClient(client: Socket) { + client.use { socket -> + socket.soTimeout = 30_000 + val input = BufferedInputStream(socket.getInputStream()) + val output = BufferedOutputStream(socket.getOutputStream()) + + val headerBytes = readHeaderBlock(input) ?: return + val requestText = headerBytes.toString(StandardCharsets.ISO_8859_1) + val lines = requestText.split("\r\n") + val requestLine = lines.firstOrNull()?.trim().orEmpty() + if (requestLine.isEmpty()) { + return + } + val parts = requestLine.split(" ", limit = 3) + if (parts.size < 2) { + return + } + + val method = parts[0].uppercase(Locale.US) + if (method == "CONNECT") { + val (host, port) = splitHostPort(parts[1], 443) + handleConnectTunnel(host, port, socket, input, output, "HTTP CONNECT") + return + } + + val target = resolveHttpTarget(parts[1], lines) + if (target == null) { + sendSimpleResponse(output, 400, "Bad Request", "No target host") + return + } + + log("HTTP ${target.host}:${target.port} ${target.path}") + Socket().use { remote -> + remote.connect(InetSocketAddress(target.host, target.port), 10_000) + remote.tcpNoDelay = true + val remoteOut = BufferedOutputStream(remote.getOutputStream()) + + val rebuilt = rebuildHttpRequest(parts, lines, target.path) + remoteOut.write(rebuilt.toByteArray(StandardCharsets.ISO_8859_1)) + remoteOut.flush() + + tunnel(socket, input, output, remote) + } + } + } + + private suspend fun handleSocks5Client(client: Socket) { + client.use { socket -> + socket.soTimeout = 30_000 + val input = BufferedInputStream(socket.getInputStream()) + val output = BufferedOutputStream(socket.getOutputStream()) + + val version = input.read() + val methodCount = input.read() + if (version != 0x05 || methodCount <= 0) { + return + } + val methods = ByteArray(methodCount) + input.readFully(methods) + + if (methods.none { it.toInt() == 0x00 }) { + output.write(byteArrayOf(0x05, 0xFF.toByte())) + output.flush() + return + } + + output.write(byteArrayOf(0x05, 0x00)) + output.flush() + + val reqHead = ByteArray(4) + input.readFully(reqHead) + if (reqHead[0].toInt() != 0x05) { + return + } + val cmd = reqHead[1].toInt() and 0xFF + val atyp = reqHead[3].toInt() and 0xFF + if (cmd != 0x01) { + sendSocksReply(output, 0x07) + return + } + + val host = readSocksAddress(input, atyp) ?: run { + sendSocksReply(output, 0x08) + return + } + val port = ((input.read() and 0xFF) shl 8) or (input.read() and 0xFF) + log("SOCKS5 $host:$port") + + Socket().use { remote -> + remote.connect(InetSocketAddress(host, port), 10_000) + remote.tcpNoDelay = true + sendSocksReply(output, 0x00) + tunnel(socket, input, output, remote) + } + } + } + + private suspend fun handleConnectTunnel( + host: String, + port: Int, + client: Socket, + clientInput: InputStream, + clientOutput: OutputStream, + label: String, + ) { + log("$label $host:$port") + Socket().use { remote -> + remote.connect(InetSocketAddress(host, port), 10_000) + remote.tcpNoDelay = true + clientOutput.write("HTTP/1.1 200 Connection Established\r\n\r\n".toByteArray()) + clientOutput.flush() + tunnel(client, clientInput, clientOutput, remote) + } + } + + private suspend fun tunnel( + client: Socket, + clientInput: InputStream, + clientOutput: OutputStream, + remote: Socket, + ) { + val remoteInput = BufferedInputStream(remote.getInputStream()) + val remoteOutput = BufferedOutputStream(remote.getOutputStream()) + + val aToB: Job = scope.launch { + copyStream(clientInput, remoteOutput) + closeQuietly(remoteOutput) + } + val bToA: Job = scope.launch { + copyStream(remoteInput, clientOutput) + closeQuietly(clientOutput) + } + + aToB.join() + bToA.join() + closeQuietly(client) + closeQuietly(remote) + } + + private fun copyStream(input: InputStream, output: OutputStream) { + val buffer = ByteArray(16 * 1024) + while (true) { + val count = try { + input.read(buffer) + } catch (_: Exception) { + -1 + } + if (count <= 0) { + break + } + try { + output.write(buffer, 0, count) + output.flush() + } catch (_: Exception) { + break + } + } + } + + private fun readHeaderBlock(input: InputStream): ByteArray? { + val buffer = ByteArrayOutputStream() + var matched = 0 + val sentinel = byteArrayOf('\r'.code.toByte(), '\n'.code.toByte(), '\r'.code.toByte(), '\n'.code.toByte()) + while (buffer.size() < 64 * 1024) { + val b = input.read() + if (b < 0) { + return if (buffer.size() == 0) null else buffer.toByteArray() + } + val value = b.toByte() + buffer.write(value.toInt()) + matched = if (value == sentinel[matched]) matched + 1 else if (value == sentinel[0]) 1 else 0 + if (matched == sentinel.size) { + return buffer.toByteArray() + } + } + return null + } + + private fun splitHostPort(target: String, defaultPort: Int): Pair { + val value = target.trim() + if (value.startsWith("[")) { + val host = value.substringAfter("[").substringBefore("]") + val port = value.substringAfter("]:", defaultPort.toString()).toIntOrNull() ?: defaultPort + return host to port + } + val idx = value.lastIndexOf(':') + if (idx > 0 && value.indexOf(':') == idx) { + val host = value.substring(0, idx) + val port = value.substring(idx + 1).toIntOrNull() ?: defaultPort + return host to port + } + return value to defaultPort + } + + private fun resolveHttpTarget(rawTarget: String, lines: List): HttpTarget? { + return if (rawTarget.startsWith("http://") || rawTarget.startsWith("https://")) { + val uri = URI(rawTarget) + val host = uri.host ?: return null + val port = if (uri.port > 0) uri.port else if (uri.scheme == "https") 443 else 80 + val path = buildString { + append(uri.rawPath ?: "/") + if (!uri.rawQuery.isNullOrBlank()) { + append('?') + append(uri.rawQuery) + } + } + HttpTarget(host, port, path) + } else { + val hostHeader = lines.firstOrNull { it.startsWith("Host:", ignoreCase = true) } + ?.substringAfter(':') + ?.trim() + ?: return null + val (host, port) = splitHostPort(hostHeader, 80) + HttpTarget(host, port, rawTarget.ifBlank { "/" }) + } + } + + private fun rebuildHttpRequest(parts: List, lines: List, path: String): String { + val requestLine = buildString { + append(parts[0]) + append(' ') + append(path) + if (parts.size >= 3) { + append(' ') + append(parts[2]) + } + } + + val rewrittenHeaders = buildList { + add(requestLine) + for (line in lines.drop(1)) { + if (line.isEmpty()) { + continue + } + val lower = line.substringBefore(':').trim().lowercase(Locale.US) + if (lower == "proxy-connection") { + continue + } + add(line) + } + } + + return rewrittenHeaders.joinToString("\r\n", postfix = "\r\n\r\n") + } + + private fun sendSimpleResponse(output: OutputStream, status: Int, reason: String, body: String) { + val bodyBytes = body.toByteArray(StandardCharsets.UTF_8) + val response = buildString { + append("HTTP/1.1 ") + append(status) + append(' ') + append(reason) + append("\r\nContent-Type: text/plain; charset=utf-8") + append("\r\nContent-Length: ") + append(bodyBytes.size) + append("\r\nConnection: close\r\n\r\n") + }.toByteArray(StandardCharsets.UTF_8) + output.write(response) + output.write(bodyBytes) + output.flush() + } + + private fun sendSocksReply(output: OutputStream, code: Int) { + output.write(byteArrayOf(0x05, code.toByte(), 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)) + output.flush() + } + + private fun readSocksAddress(input: InputStream, atyp: Int): String? { + return when (atyp) { + 0x01 -> { + val bytes = ByteArray(4) + input.readFully(bytes) + bytes.joinToString(".") { (it.toInt() and 0xFF).toString() } + } + 0x03 -> { + val len = input.read() + if (len <= 0) return null + val bytes = ByteArray(len) + input.readFully(bytes) + bytes.toString(StandardCharsets.US_ASCII) + } + 0x04 -> { + val bytes = ByteArray(16) + input.readFully(bytes) + InetAddress.getByAddress(bytes).hostAddress + } + else -> null + } + } + + private fun InputStream.readFully(dst: ByteArray) { + var offset = 0 + while (offset < dst.size) { + val count = read(dst, offset, dst.size - offset) + if (count < 0) { + throw IllegalStateException("Unexpected EOF") + } + offset += count + } + } + + private fun closeQuietly(closeable: Any?) { + when (closeable) { + is Closeable -> runCatching { closeable.close() } + is Socket -> runCatching { closeable.close() } + is ServerSocket -> runCatching { closeable.close() } + } + } + + private data class HttpTarget( + val host: String, + val port: Int, + val path: String, + ) +} diff --git a/android-native/app/src/main/java/ir/masteralidns/androidproxy/MainActivity.kt b/android-native/app/src/main/java/ir/masteralidns/androidproxy/MainActivity.kt new file mode 100644 index 0000000..73f56b0 --- /dev/null +++ b/android-native/app/src/main/java/ir/masteralidns/androidproxy/MainActivity.kt @@ -0,0 +1,65 @@ +package ir.masteralidns.androidproxy + +import android.content.Intent +import android.os.Bundle +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat + +class MainActivity : AppCompatActivity() { + private lateinit var configEditor: EditText + private lateinit var statusView: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + configEditor = findViewById(R.id.configEditor) + statusView = findViewById(R.id.statusView) + val startButton: Button = findViewById(R.id.startButton) + val stopButton: Button = findViewById(R.id.stopButton) + + val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + configEditor.setText(prefs.getString(KEY_CONFIG, ProxyConfig.defaultJson())) + + startButton.setOnClickListener { + val rawConfig = configEditor.text.toString().trim() + if (rawConfig.isEmpty()) { + Toast.makeText(this, "Config is empty", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + runCatching { ProxyConfig.fromJson(rawConfig) } + .onFailure { + Toast.makeText(this, "Config error: ${it.message}", Toast.LENGTH_LONG).show() + return@setOnClickListener + } + + prefs.edit().putString(KEY_CONFIG, rawConfig).apply() + val intent = Intent(this, ProxyService::class.java).apply { + action = ProxyService.ACTION_START + putExtra(ProxyService.EXTRA_CONFIG, rawConfig) + } + ContextCompat.startForegroundService(this, intent) + statusView.setText(R.string.status_running) + } + + stopButton.setOnClickListener { + ContextCompat.startForegroundService( + this, + Intent(this, ProxyService::class.java).apply { + action = ProxyService.ACTION_STOP + }, + ) + statusView.setText(R.string.status_idle) + } + } + + companion object { + private const val PREFS_NAME = "proxy_prefs" + private const val KEY_CONFIG = "config_json" + } +} diff --git a/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyConfig.kt b/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyConfig.kt new file mode 100644 index 0000000..740c77a --- /dev/null +++ b/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyConfig.kt @@ -0,0 +1,54 @@ +package ir.masteralidns.androidproxy + +import org.json.JSONArray +import org.json.JSONObject + +data class ProxyConfig( + val listenHost: String = "127.0.0.1", + val listenPort: Int = 8085, + val socks5Enabled: Boolean = true, + val socks5Port: Int = 1080, + val directDomains: List = emptyList(), +) { + fun toJsonString(): String { + val obj = JSONObject() + obj.put("listen_host", listenHost) + obj.put("listen_port", listenPort) + obj.put("socks5_enabled", socks5Enabled) + obj.put("socks5_port", socks5Port) + obj.put("direct_domains", JSONArray(directDomains)) + return obj.toString(2) + } + + companion object { + fun defaultConfig(): ProxyConfig = ProxyConfig( + directDomains = listOf( + "chatgpt.com", + "openai.com", + "oaistatic.com", + "oaiusercontent.com", + ), + ) + + fun defaultJson(): String = defaultConfig().toJsonString() + + fun fromJson(raw: String): ProxyConfig { + val obj = JSONObject(raw) + val directDomains = mutableListOf() + val arr = obj.optJSONArray("direct_domains") ?: JSONArray() + for (i in 0 until arr.length()) { + val value = arr.optString(i).trim() + if (value.isNotEmpty()) { + directDomains += value + } + } + return ProxyConfig( + listenHost = obj.optString("listen_host", "127.0.0.1"), + listenPort = obj.optInt("listen_port", 8085), + socks5Enabled = obj.optBoolean("socks5_enabled", true), + socks5Port = obj.optInt("socks5_port", 1080), + directDomains = directDomains, + ) + } + } +} diff --git a/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyService.kt b/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyService.kt new file mode 100644 index 0000000..95c50e8 --- /dev/null +++ b/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyService.kt @@ -0,0 +1,131 @@ +package ir.masteralidns.androidproxy + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat + +class ProxyService : Service() { + private var engine: LocalProxyEngine? = null + private var currentConfig: ProxyConfig? = null + + override fun onCreate() { + super.onCreate() + ensureNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + stopEngine() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + ACTION_START -> { + val rawConfig = intent.getStringExtra(EXTRA_CONFIG) ?: ProxyConfig.defaultJson() + return runCatching { + startWithConfig(ProxyConfig.fromJson(rawConfig)) + START_STICKY + }.getOrElse { error -> + startForeground(NOTIFICATION_ID, buildNotification("Config error: ${error.message}")) + stopSelf() + START_NOT_STICKY + } + } + else -> return START_NOT_STICKY + } + } + + override fun onDestroy() { + stopEngine() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startWithConfig(config: ProxyConfig) { + stopEngine() + currentConfig = config + startForeground(NOTIFICATION_ID, buildNotification(summary(config))) + + engine = LocalProxyEngine(config) { message -> + val active = currentConfig ?: return@LocalProxyEngine + val text = summary(active) + " | $message" + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, buildNotification(text)) + }.also { it.start() } + } + + private fun stopEngine() { + engine?.stop() + engine = null + } + + private fun summary(config: ProxyConfig): String { + return if (config.socks5Enabled) { + "HTTP ${config.listenHost}:${config.listenPort} | SOCKS5 ${config.listenHost}:${config.socks5Port}" + } else { + "HTTP ${config.listenHost}:${config.listenPort}" + } + } + + private fun buildNotification(text: String): Notification { + val openIntent = PendingIntent.getActivity( + this, + 1, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val stopIntent = PendingIntent.getService( + this, + 2, + Intent(this, ProxyService::class.java).apply { + action = ACTION_STOP + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentIntent(openIntent) + .addAction(0, getString(R.string.stop_proxy), stopIntent) + .setOngoing(true) + .build() + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.notification_channel_desc) + } + manager.createNotificationChannel(channel) + } + + companion object { + private const val CHANNEL_ID = "proxy_service" + private const val NOTIFICATION_ID = 1001 + + const val ACTION_START = "ir.masteralidns.androidproxy.START" + const val ACTION_STOP = "ir.masteralidns.androidproxy.STOP" + const val EXTRA_CONFIG = "config" + } +} diff --git a/android-native/app/src/main/res/layout/activity_main.xml b/android-native/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..cd49c2c --- /dev/null +++ b/android-native/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + +