Compare commits
8 Commits
88683d5290
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ab932abab4 | |||
| fb01a229fc | |||
| 873d72747b | |||
| 3c20028212 | |||
| 443961222f | |||
| 381c5649ea | |||
| 9e9de4d4f1 | |||
| 6247dd6228 |
@@ -1,5 +1,4 @@
|
||||
# Secrets & user config
|
||||
config.json
|
||||
.env
|
||||
|
||||
# CA certificates (generated at runtime, contain private keys)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"_comment": "Copy this file to config.json and fill in your values",
|
||||
"mode": "apps_script",
|
||||
"google_ip": "216.239.38.120",
|
||||
"front_domain": "www.google.com",
|
||||
"script_id": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID",
|
||||
"auth_key": "CHANGE_ME_TO_A_STRONG_SECRET",
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 8085,
|
||||
"log_level": "INFO",
|
||||
"verify_ssl": true,
|
||||
"_hosts_comment": "Optional SNI-rewrite overrides. YouTube, googlevideo, gstatic, fonts.googleapis.com, ytimg, ggpht, doubleclick, etc. are ALREADY handled automatically (routed via google_ip with SNI=front_domain, same trick as the Xray MITM-DomainFronting config). Add entries here only for custom domains, e.g. \"example.com\": \"216.239.38.120\".",
|
||||
"hosts": {}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"mode": "apps_script",
|
||||
"google_ip": "216.239.38.120",
|
||||
"front_domain": "www.google.com",
|
||||
"script_id": "AKfycbz4pDHGjE3KbcOPLr3q4_m9Vw_JGsUQtNWws9CjKCJtf4jY6cCQdD5wQWRVxe_LD8qCRA",
|
||||
"auth_key": "14726270-e9a3-4381-a008-751414382c82",
|
||||
"listen_host": "127.0.0.1",
|
||||
"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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,15 +40,19 @@ class MITMCertManager:
|
||||
|
||||
def _ensure_ca(self):
|
||||
if os.path.exists(CA_KEY_FILE) and os.path.exists(CA_CERT_FILE):
|
||||
with open(CA_KEY_FILE, "rb") as f:
|
||||
self._ca_key = serialization.load_pem_private_key(
|
||||
f.read(), password=None
|
||||
)
|
||||
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:
|
||||
self._create_ca()
|
||||
try:
|
||||
with open(CA_KEY_FILE, "rb") as f:
|
||||
self._ca_key = serialization.load_pem_private_key(
|
||||
f.read(), password=None
|
||||
)
|
||||
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)
|
||||
return
|
||||
except Exception as exc:
|
||||
log.warning("Existing CA is unreadable, generating a new one: %s", exc)
|
||||
|
||||
self._create_ca()
|
||||
|
||||
def _create_ca(self):
|
||||
os.makedirs(CA_DIR, exist_ok=True)
|
||||
@@ -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)
|
||||
|
||||
+157
-17
@@ -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)
|
||||
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()
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user