Compare commits

...

6 Commits

Author SHA1 Message Date
asus ab932abab4 add kotlin project 2026-04-22 19:52:54 +03:30
asus fb01a229fc add direct_domains 2026-04-22 19:44:09 +03:30
asus 873d72747b fix socks 2026-04-22 19:28:25 +03:30
asus 3c20028212 update readme 2026-04-22 19:12:24 +03:30
asus 443961222f fix cert 2026-04-22 19:11:24 +03:30
asus 381c5649ea fix some certificate 2026-04-22 19:02:48 +03:30
21 changed files with 1308 additions and 38 deletions
+6
View File
@@ -1,5 +1,6 @@
# MasterHttpRelayVPN
**[English README](README.md)**
یک ابزار رایگان برای عبور از فیلترینگ و DPI که ترافیک شما را پشت دامنه‌های قابل اعتماد مثل Google پنهان می‌کند. برای حالت ساده، به VPS یا سرور نیاز ندارید و فقط یک اکانت Google کافی است.
@@ -36,6 +37,11 @@ cd MasterHttpRelayVPN
pip install -r requirements.txt
```
```bash
termux-setup-storage
python main.py --install-cert
```
> **دسترسی به PyPI ندارید؟** از این mirror استفاده کنید:
> ```bash
> pip install -r requirements.txt -i https://mirror-pypi.runflare.com/simple/ --trusted-host mirror-pypi.runflare.com
+34
View File
@@ -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
+43
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# Intentionally minimal for the first Android-native slice.
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MasterAliDnsAndroid">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".ProxyService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -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<ServerSocket>()
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<String, Int> {
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<String>): 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<String>, lines: List<String>, 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,
)
}
@@ -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"
}
}
@@ -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<String> = 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<String>()
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,
)
}
}
}
@@ -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"
}
}
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/titleView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/title_main"
android:textAppearance="?attr/textAppearanceHeadlineSmall" />
<TextView
android:id="@+id/subtitleView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/subtitle_main"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<TextView
android:id="@+id/statusView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/status_idle"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<EditText
android:id="@+id/configEditor"
android:layout_width="match_parent"
android:layout_height="320dp"
android:layout_marginTop="16dp"
android:gravity="top|start"
android:hint="@string/config_hint"
android:inputType="textMultiLine|textNoSuggestions"
android:fontFamily="monospace"
android:padding="12dp"
android:scrollbars="vertical" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal">
<Button
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/start_proxy" />
<Space
android:layout_width="12dp"
android:layout_height="1dp" />
<Button
android:id="@+id/stopButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/stop_proxy" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="proxy_primary">#0F766E</color>
<color name="proxy_primary_dark">#134E4A</color>
<color name="proxy_surface">#F6F6F2</color>
<color name="proxy_text">#172321</color>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">MasterAliDns Android</string>
<string name="title_main">Android Native Proxy</string>
<string name="subtitle_main">First Kotlin slice: local HTTP and SOCKS5 proxy service on-device.</string>
<string name="status_idle">Status: stopped</string>
<string name="status_running">Status: running</string>
<string name="config_hint">Paste JSON config here</string>
<string name="start_proxy">Start</string>
<string name="stop_proxy">Stop</string>
<string name="notification_title">MasterAliDns proxy is running</string>
<string name="notification_channel_name">Proxy service</string>
<string name="notification_channel_desc">Keeps the local proxy alive in the foreground.</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.MasterAliDnsAndroid" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/proxy_primary</item>
<item name="colorPrimaryVariant">@color/proxy_primary_dark</item>
<item name="colorOnPrimary">@android:color/white</item>
<item name="android:windowBackground">@color/proxy_surface</item>
<item name="android:textColor">@color/proxy_text</item>
<item name="colorSurface">@color/proxy_surface</item>
<item name="colorOnSurface">@color/proxy_text</item>
<item name="android:statusBarColor" tools:targetApi="l">@color/proxy_primary_dark</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MasterAliDnsAndroid"
include(":app")
+71 -1
View File
@@ -40,6 +40,46 @@ def _has_cmd(name: str) -> bool:
return shutil.which(name) is not None
def _is_termux() -> bool:
prefix = os.environ.get("PREFIX", "")
return (
"com.termux/files/usr" in prefix
or "com.termux/files/usr" in os.environ.get("PATH", "")
or bool(os.environ.get("TERMUX_VERSION"))
or os.path.exists("/data/data/com.termux/files/usr")
)
def _android_cert_hint() -> str:
return (
"Import the certificate in Android Settings > Security > "
"Encryption & credentials > Install a certificate > CA certificate."
)
def _stage_android_cert(cert_path: str, cert_name: str) -> str | None:
"""
Copy the CA cert to a user-visible location on Android/Termux so the user
can import it through Android's certificate installer UI.
"""
base = cert_name.replace(" ", "_")
candidates = [
os.path.expanduser("~/storage/downloads"),
"/sdcard/Download",
"/storage/emulated/0/Download",
]
for folder in candidates:
if not os.path.isdir(folder):
continue
dest = os.path.join(folder, f"{base}.crt")
try:
shutil.copy2(cert_path, dest)
return dest
except OSError:
continue
return None
# ─────────────────────────────────────────────────────────────────────────────
# Windows
# ─────────────────────────────────────────────────────────────────────────────
@@ -275,6 +315,31 @@ def _is_trusted_linux(cert_path: str) -> bool:
return False
def _install_termux_android(cert_path: str, cert_name: str) -> bool:
"""
Android does not allow a normal user-space process to silently install a CA
into the OS/browser trust store. On Termux we can only stage the file and
guide the user to Android's certificate installer.
"""
staged = _stage_android_cert(cert_path, cert_name)
log.warning("Detected Termux/Android environment.")
log.warning(
"Automatic CA installation into Android's system/browser trust store "
"is not supported from Termux without user interaction."
)
if staged:
log.warning("Certificate copied to: %s", staged)
else:
log.warning(
"Could not copy the certificate to shared storage automatically. "
"Run 'termux-setup-storage' in Termux first, then try again."
)
log.warning("You can also import this file directly: %s", cert_path)
log.warning(_android_cert_hint())
log.warning("After import, fully close and reopen the browser.")
return False
# ─────────────────────────────────────────────────────────────────────────────
# Firefox NSS (cross-platform)
# ─────────────────────────────────────────────────────────────────────────────
@@ -326,6 +391,9 @@ def is_ca_trusted(cert_path: str) -> bool:
"""Return True if the CA cert appears to be already installed."""
system = platform.system()
try:
if _is_termux():
# Android's user/system CA stores are not directly inspectable here.
return False
if system == "Windows":
return _is_trusted_windows(cert_path)
if system == "Darwin":
@@ -349,7 +417,9 @@ def install_ca(cert_path: str, cert_name: str = CERT_NAME) -> bool:
system = platform.system()
log.info("Installing CA certificate on %s", system)
if system == "Windows":
if _is_termux():
ok = _install_termux_android(cert_path, cert_name)
elif system == "Windows":
ok = _install_windows(cert_path, cert_name)
elif system == "Darwin":
ok = _install_macos(cert_path, cert_name)
+6
View File
@@ -8,6 +8,12 @@
"listen_port": 8085,
"socks5_enabled": true,
"socks5_port": 1080,
"direct_domains": [
"chatgpt.com",
"openai.com",
"oaistatic.com",
"oaiusercontent.com"
],
"log_level": "INFO",
"verify_ssl": true
}
+97
View File
@@ -12,6 +12,8 @@ import asyncio
import json
import logging
import os
import re
import subprocess
import sys
from cert_installer import install_ca, is_ca_trusted
@@ -75,6 +77,81 @@ def parse_args():
return parser.parse_args()
def _windows_listener_details(host: str, port: int) -> tuple[str, str] | None:
"""Best-effort lookup of the process listening on host:port on Windows."""
if os.name != "nt":
return None
try:
result = subprocess.run(
["netstat", "-ano", "-p", "tcp"],
capture_output=True,
text=True,
check=True,
)
except Exception:
return None
port_suffix = f":{port}"
host_variants = {
f"{host}:{port}",
f"0.0.0.0:{port}",
f"[::]:{port}",
f"[::1]:{port}",
f"::{port}",
}
pid = None
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) < 5 or parts[0] != "TCP":
continue
local_addr, state, candidate_pid = parts[1], parts[3].upper(), parts[4]
if state != "LISTENING":
continue
if local_addr in host_variants or local_addr.endswith(port_suffix):
pid = candidate_pid
break
if not pid:
return None
try:
proc = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
check=True,
)
line = proc.stdout.strip().splitlines()[0]
name = line.split(",")[0].strip('"') if line else "unknown"
except Exception:
name = "unknown"
return pid, name
def _is_addr_in_use_error(exc: OSError) -> bool:
text = str(exc).lower()
return (
getattr(exc, "errno", None) in {48, 98, 10048}
or getattr(exc, "winerror", None) == 10048
or "address already in use" in text
or "only one usage of each socket address" in text
)
def _bind_target_from_error(exc: OSError, config: dict) -> tuple[str, int]:
text = str(exc)
match = re.search(r"\('([^']+)',\s*(\d+)\)", text)
if match:
return match.group(1), int(match.group(2))
return (
config.get("listen_host", "127.0.0.1"),
config.get("listen_port", 8080),
)
def main():
args = parse_args()
config_path = args.config
@@ -151,6 +228,13 @@ def main():
mode = config.get("mode", "domain_fronting")
log.info("DomainFront Tunnel starting (mode: %s)", mode)
if config.get("socks5_enabled"):
log.info(
"SOCKS5 address : %s:%d",
config.get("listen_host", "127.0.0.1"),
config.get("socks5_port", 1080),
)
if mode == "custom_domain":
log.info("Custom domain : %s", config["custom_domain"])
elif mode == "google_fronting":
@@ -196,6 +280,19 @@ def main():
try:
asyncio.run(ProxyServer(config).start())
except OSError as e:
if _is_addr_in_use_error(e):
host, port = _bind_target_from_error(e, config)
log.error("Cannot listen on %s:%d because that address is already in use.", host, port)
details = _windows_listener_details(host, port)
if details:
pid, name = details
log.error("Port %d is currently held by PID %s (%s).", port, pid, name)
log.error(
"Stop the other process or choose another port, for example: python main.py -p 9090"
)
sys.exit(1)
raise
except KeyboardInterrupt:
log.info("Stopped")
+78 -11
View File
@@ -11,15 +11,17 @@ Requires: pip install cryptography
"""
import datetime
import ipaddress
import logging
import os
import re
import ssl
import tempfile
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
log = logging.getLogger("MITM")
@@ -38,6 +40,7 @@ class MITMCertManager:
def _ensure_ca(self):
if os.path.exists(CA_KEY_FILE) and os.path.exists(CA_CERT_FILE):
try:
with open(CA_KEY_FILE, "rb") as f:
self._ca_key = serialization.load_pem_private_key(
f.read(), password=None
@@ -45,7 +48,10 @@ class MITMCertManager:
with open(CA_CERT_FILE, "rb") as f:
self._ca_cert = x509.load_pem_x509_certificate(f.read())
log.info("Loaded CA from %s", CA_DIR)
else:
return
except Exception as exc:
log.warning("Existing CA is unreadable, generating a new one: %s", exc)
self._create_ca()
def _create_ca(self):
@@ -59,13 +65,14 @@ class MITMCertManager:
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MasterHttpRelayVPN"),
])
now = datetime.datetime.now(datetime.timezone.utc)
ca_public_key = self._ca_key.public_key()
self._ca_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(self._ca_key.public_key())
.public_key(ca_public_key)
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(
x509.BasicConstraints(ca=True, path_length=0), critical=True
@@ -84,6 +91,14 @@ class MITMCertManager:
),
critical=True,
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(ca_public_key),
critical=False,
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_public_key),
critical=False,
)
.sign(self._ca_key, hashes.SHA256())
)
@@ -105,8 +120,9 @@ class MITMCertManager:
if domain not in self._ctx_cache:
key_pem, cert_pem = self._generate_domain_cert(domain)
cert_file = os.path.join(self._cert_dir, f"{domain}.crt")
key_file = os.path.join(self._cert_dir, f"{domain}.key")
cache_name = self._safe_cache_name(domain)
cert_file = os.path.join(self._cert_dir, f"{cache_name}.crt")
key_file = os.path.join(self._cert_dir, f"{cache_name}.key")
ca_pem = self._ca_cert.public_bytes(serialization.Encoding.PEM)
with open(cert_file, "wb") as f:
@@ -122,23 +138,60 @@ class MITMCertManager:
return self._ctx_cache[domain]
def _generate_domain_cert(self, domain: str):
normalized_name, san_entries = self._build_subject_alt_names(domain)
key = rsa.generate_private_key(
public_exponent=65537, key_size=2048
)
public_key = key.public_key()
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, domain),
x509.NameAttribute(
NameOID.COMMON_NAME,
normalized_name if len(normalized_name) <= 64 else "MasterHttpRelayVPN",
),
])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(self._ca_cert.subject)
.public_key(key.public_key())
.public_key(public_key)
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=365))
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=90))
.add_extension(
x509.SubjectAlternativeName([x509.DNSName(domain)]),
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
.add_extension(
x509.KeyUsage(
digital_signature=True,
key_encipherment=True,
key_cert_sign=False,
crl_sign=False,
content_commitment=False,
data_encipherment=False,
key_agreement=False,
encipher_only=False,
decipher_only=False,
),
critical=True,
)
.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
critical=False,
)
.add_extension(
x509.SubjectAlternativeName(san_entries),
critical=False,
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(public_key),
critical=False,
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(
self._ca_key.public_key()
),
critical=False,
)
.sign(self._ca_key, hashes.SHA256())
@@ -151,3 +204,17 @@ class MITMCertManager:
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
return key_pem, cert_pem
@staticmethod
def _build_subject_alt_names(domain: str):
name = domain.strip().rstrip(".").strip("[]")
try:
ip = ipaddress.ip_address(name)
return name, [x509.IPAddress(ip)]
except ValueError:
normalized = name.encode("idna").decode("ascii")
return normalized, [x509.DNSName(normalized)]
@staticmethod
def _safe_cache_name(domain: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]", "_", domain)
+153 -13
View File
@@ -7,11 +7,14 @@ a domain-fronted connection to a CDN worker or Apps Script relay.
Supports:
- CONNECT method → WebSocket tunnel (modes 1-3) or MITM relay (apps_script)
- GET / POST etc. → HTTP forwarding (modes 1-3) or JSON relay (apps_script)
- SOCKS5 CONNECT → Same tunnel/MITM routing as the HTTP proxy
"""
import asyncio
import contextlib
import logging
import re
import socket
import ssl
import time
@@ -104,6 +107,8 @@ class ProxyServer:
def __init__(self, config: dict):
self.host = config.get("listen_host", "127.0.0.1")
self.port = config.get("listen_port", 8080)
self.socks5_enabled = bool(config.get("socks5_enabled"))
self.socks5_port = int(config.get("socks5_port", 1080))
self.mode = config.get("mode", "domain_fronting")
self.fronter = DomainFronter(config)
self.mitm = None
@@ -117,6 +122,11 @@ class ProxyServer:
# hosts override — DNS fake-map: domain/suffix → IP
# Checked before any real DNS lookup; supports exact and suffix matching.
self._hosts: dict[str, str] = config.get("hosts", {})
self._direct_domains: tuple[str, ...] = tuple(
d.lower().lstrip(".").rstrip(".")
for d in config.get("direct_domains", [])
if isinstance(d, str) and d.strip()
)
if self.mode == "apps_script":
try:
@@ -128,13 +138,33 @@ class ProxyServer:
raise SystemExit(1)
async def start(self):
srv = await asyncio.start_server(self._on_client, self.host, self.port)
servers = []
try:
http_srv = await asyncio.start_server(self._on_client, self.host, self.port)
servers.append(http_srv)
log.info(
"Listening on %s:%d — configure your browser HTTP proxy to this address",
self.host, self.port,
)
async with srv:
await srv.serve_forever()
if self.socks5_enabled:
socks_srv = await asyncio.start_server(
self._on_socks5_client, self.host, self.socks5_port
)
servers.append(socks_srv)
log.info(
"Listening on %s:%d — SOCKS5 CONNECT (no-auth, TCP only)",
self.host, self.socks5_port,
)
async with contextlib.AsyncExitStack() as stack:
for srv in servers:
await stack.enter_async_context(srv)
await asyncio.gather(*(srv.serve_forever() for srv in servers))
finally:
for srv in servers:
srv.close()
await asyncio.gather(*(srv.wait_closed() for srv in servers), return_exceptions=True)
# ── client handler ────────────────────────────────────────────
@@ -176,19 +206,79 @@ class ProxyServer:
except Exception:
pass
async def _on_socks5_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
addr = writer.get_extra_info("peername")
try:
version, nmethods = await asyncio.wait_for(reader.readexactly(2), timeout=15)
if version != 5:
return
methods = await asyncio.wait_for(reader.readexactly(nmethods), timeout=15)
if 0x00 not in methods:
writer.write(b"\x05\xff")
await writer.drain()
return
writer.write(b"\x05\x00")
await writer.drain()
version, cmd, _rsv, atyp = await asyncio.wait_for(reader.readexactly(4), timeout=30)
if version != 5:
return
host = await self._read_socks5_address(reader, atyp)
if host is None:
await self._send_socks5_reply(writer, rep=0x08)
return
port = int.from_bytes(
await asyncio.wait_for(reader.readexactly(2), timeout=15), "big"
)
if cmd != 0x01:
log.debug("SOCKS5 unsupported command %d from %s", cmd, addr)
await self._send_socks5_reply(writer, rep=0x07)
return
await self._handle_connect_target(
host,
port,
reader,
writer,
protocol="SOCKS5",
ready_cb=lambda: self._send_socks5_reply(writer, rep=0x00),
)
except asyncio.IncompleteReadError:
pass
except asyncio.TimeoutError:
log.debug("SOCKS5 timeout: %s", addr)
except Exception as e:
log.error("SOCKS5 error (%s): %s", addr, e)
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
# ── CONNECT (HTTPS tunnelling) ────────────────────────────────
async def _do_connect(self, target: str, reader, writer):
host, _, port = target.rpartition(":")
port = int(port) if port else 443
if not host:
host, port = target, 443
host, port = self._split_host_port(target, default_port=443)
await self._handle_connect_target(
host,
port,
reader,
writer,
protocol="CONNECT",
ready_cb=lambda: self._send_http_connect_ok(writer),
)
log.info("CONNECT → %s:%d", host, port)
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
await writer.drain()
async def _handle_connect_target(self, host: str, port: int, reader, writer,
*, protocol: str, ready_cb):
log.info("%s%s:%d", protocol, host, port)
await ready_cb()
if self.mode == "apps_script":
override_ip = self._sni_rewrite_ip(host)
if override_ip:
@@ -199,14 +289,54 @@ class ProxyServer:
host, override_ip, self.fronter.sni_host)
await self._do_sni_rewrite_tunnel(host, port, reader, writer,
connect_ip=override_ip)
elif self._is_google_domain(host):
log.info("Direct tunnel → %s (Google domain, skipping relay)", host)
elif self._should_direct_connect(host):
log.info("Direct tunnel → %s (skipping relay)", host)
await self._do_direct_tunnel(host, port, reader, writer)
else:
await self._do_mitm_connect(host, port, reader, writer)
else:
await self.fronter.tunnel(host, port, reader, writer)
@staticmethod
def _split_host_port(target: str, default_port: int) -> tuple[str, int]:
target = target.strip()
if target.startswith("["):
host, _, port_str = target[1:].partition("]:")
return host, int(port_str) if port_str else default_port
host, _, port_str = target.rpartition(":")
if host:
return host, int(port_str)
return target, default_port
@staticmethod
async def _send_http_connect_ok(writer):
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
await writer.drain()
@staticmethod
async def _read_socks5_address(reader: asyncio.StreamReader, atyp: int) -> str | None:
if atyp == 0x01:
return socket.inet_ntoa(await reader.readexactly(4))
if atyp == 0x03:
length = (await reader.readexactly(1))[0]
return (await reader.readexactly(length)).decode("ascii", errors="replace")
if atyp == 0x04:
return socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16))
return None
@staticmethod
async def _send_socks5_reply(writer, *, rep: int,
bind_host: str = "0.0.0.0", bind_port: int = 0):
try:
packed_host = socket.inet_aton(bind_host)
atyp = 0x01
except OSError:
packed_host = b"\x00\x00\x00\x00"
atyp = 0x01
reply = b"\x05" + bytes([rep, 0x00, atyp]) + packed_host + bind_port.to_bytes(2, "big")
writer.write(reply)
await writer.drain()
# ── Hosts override (fake DNS) ─────────────────────────────────
# Built-in list of domains that must be reached via Google's frontend IP
@@ -290,6 +420,16 @@ class ProxyServer:
return True
return False
def _should_direct_connect(self, host: str) -> bool:
"""Return True if host should bypass MITM and use raw CONNECT."""
if self._is_google_domain(host):
return True
h = host.lower().rstrip(".")
for suffix in self._direct_domains:
if h == suffix or h.endswith("." + suffix):
return True
return False
# ── Direct tunnel (no MITM) ───────────────────────────────────
async def _do_direct_tunnel(self, host: str, port: int,