Compare commits
6 Commits
9e9de4d4f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ab932abab4 | |||
| fb01a229fc | |||
| 873d72747b | |||
| 3c20028212 | |||
| 443961222f | |||
| 381c5649ea |
@@ -1,5 +1,6 @@
|
|||||||
# MasterHttpRelayVPN
|
# MasterHttpRelayVPN
|
||||||
|
|
||||||
|
|
||||||
**[English README](README.md)**
|
**[English README](README.md)**
|
||||||
|
|
||||||
یک ابزار رایگان برای عبور از فیلترینگ و DPI که ترافیک شما را پشت دامنههای قابل اعتماد مثل Google پنهان میکند. برای حالت ساده، به VPS یا سرور نیاز ندارید و فقط یک اکانت Google کافی است.
|
یک ابزار رایگان برای عبور از فیلترینگ و DPI که ترافیک شما را پشت دامنههای قابل اعتماد مثل Google پنهان میکند. برای حالت ساده، به VPS یا سرور نیاز ندارید و فقط یک اکانت Google کافی است.
|
||||||
@@ -36,6 +37,11 @@ cd MasterHttpRelayVPN
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
termux-setup-storage
|
||||||
|
python main.py --install-cert
|
||||||
|
```
|
||||||
|
|
||||||
> **دسترسی به PyPI ندارید؟** از این mirror استفاده کنید:
|
> **دسترسی به PyPI ندارید؟** از این mirror استفاده کنید:
|
||||||
> ```bash
|
> ```bash
|
||||||
> pip install -r requirements.txt -i https://mirror-pypi.runflare.com/simple/ --trusted-host mirror-pypi.runflare.com
|
> 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
|
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
|
# Windows
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -275,6 +315,31 @@ def _is_trusted_linux(cert_path: str) -> bool:
|
|||||||
return False
|
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)
|
# 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."""
|
"""Return True if the CA cert appears to be already installed."""
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
try:
|
try:
|
||||||
|
if _is_termux():
|
||||||
|
# Android's user/system CA stores are not directly inspectable here.
|
||||||
|
return False
|
||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
return _is_trusted_windows(cert_path)
|
return _is_trusted_windows(cert_path)
|
||||||
if system == "Darwin":
|
if system == "Darwin":
|
||||||
@@ -349,7 +417,9 @@ def install_ca(cert_path: str, cert_name: str = CERT_NAME) -> bool:
|
|||||||
system = platform.system()
|
system = platform.system()
|
||||||
log.info("Installing CA certificate on %s…", 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)
|
ok = _install_windows(cert_path, cert_name)
|
||||||
elif system == "Darwin":
|
elif system == "Darwin":
|
||||||
ok = _install_macos(cert_path, cert_name)
|
ok = _install_macos(cert_path, cert_name)
|
||||||
|
|||||||
+7
-1
@@ -8,6 +8,12 @@
|
|||||||
"listen_port": 8085,
|
"listen_port": 8085,
|
||||||
"socks5_enabled": true,
|
"socks5_enabled": true,
|
||||||
"socks5_port": 1080,
|
"socks5_port": 1080,
|
||||||
|
"direct_domains": [
|
||||||
|
"chatgpt.com",
|
||||||
|
"openai.com",
|
||||||
|
"oaistatic.com",
|
||||||
|
"oaiusercontent.com"
|
||||||
|
],
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"verify_ssl": true
|
"verify_ssl": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from cert_installer import install_ca, is_ca_trusted
|
from cert_installer import install_ca, is_ca_trusted
|
||||||
@@ -75,6 +77,81 @@ def parse_args():
|
|||||||
return parser.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():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
config_path = args.config
|
config_path = args.config
|
||||||
@@ -151,6 +228,13 @@ def main():
|
|||||||
mode = config.get("mode", "domain_fronting")
|
mode = config.get("mode", "domain_fronting")
|
||||||
log.info("DomainFront Tunnel starting (mode: %s)", mode)
|
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":
|
if mode == "custom_domain":
|
||||||
log.info("Custom domain : %s", config["custom_domain"])
|
log.info("Custom domain : %s", config["custom_domain"])
|
||||||
elif mode == "google_fronting":
|
elif mode == "google_fronting":
|
||||||
@@ -196,6 +280,19 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(ProxyServer(config).start())
|
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:
|
except KeyboardInterrupt:
|
||||||
log.info("Stopped")
|
log.info("Stopped")
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,17 @@ Requires: pip install cryptography
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import ssl
|
import ssl
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from cryptography.x509.oid import NameOID
|
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||||
|
|
||||||
log = logging.getLogger("MITM")
|
log = logging.getLogger("MITM")
|
||||||
|
|
||||||
@@ -38,15 +40,19 @@ class MITMCertManager:
|
|||||||
|
|
||||||
def _ensure_ca(self):
|
def _ensure_ca(self):
|
||||||
if os.path.exists(CA_KEY_FILE) and os.path.exists(CA_CERT_FILE):
|
if os.path.exists(CA_KEY_FILE) and os.path.exists(CA_CERT_FILE):
|
||||||
with open(CA_KEY_FILE, "rb") as f:
|
try:
|
||||||
self._ca_key = serialization.load_pem_private_key(
|
with open(CA_KEY_FILE, "rb") as f:
|
||||||
f.read(), password=None
|
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())
|
with open(CA_CERT_FILE, "rb") as f:
|
||||||
log.info("Loaded CA from %s", CA_DIR)
|
self._ca_cert = x509.load_pem_x509_certificate(f.read())
|
||||||
else:
|
log.info("Loaded CA from %s", CA_DIR)
|
||||||
self._create_ca()
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Existing CA is unreadable, generating a new one: %s", exc)
|
||||||
|
|
||||||
|
self._create_ca()
|
||||||
|
|
||||||
def _create_ca(self):
|
def _create_ca(self):
|
||||||
os.makedirs(CA_DIR, exist_ok=True)
|
os.makedirs(CA_DIR, exist_ok=True)
|
||||||
@@ -59,13 +65,14 @@ class MITMCertManager:
|
|||||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MasterHttpRelayVPN"),
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MasterHttpRelayVPN"),
|
||||||
])
|
])
|
||||||
now = datetime.datetime.now(datetime.timezone.utc)
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
ca_public_key = self._ca_key.public_key()
|
||||||
self._ca_cert = (
|
self._ca_cert = (
|
||||||
x509.CertificateBuilder()
|
x509.CertificateBuilder()
|
||||||
.subject_name(subject)
|
.subject_name(subject)
|
||||||
.issuer_name(issuer)
|
.issuer_name(issuer)
|
||||||
.public_key(self._ca_key.public_key())
|
.public_key(ca_public_key)
|
||||||
.serial_number(x509.random_serial_number())
|
.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))
|
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||||
.add_extension(
|
.add_extension(
|
||||||
x509.BasicConstraints(ca=True, path_length=0), critical=True
|
x509.BasicConstraints(ca=True, path_length=0), critical=True
|
||||||
@@ -84,6 +91,14 @@ class MITMCertManager:
|
|||||||
),
|
),
|
||||||
critical=True,
|
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())
|
.sign(self._ca_key, hashes.SHA256())
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,8 +120,9 @@ class MITMCertManager:
|
|||||||
if domain not in self._ctx_cache:
|
if domain not in self._ctx_cache:
|
||||||
key_pem, cert_pem = self._generate_domain_cert(domain)
|
key_pem, cert_pem = self._generate_domain_cert(domain)
|
||||||
|
|
||||||
cert_file = os.path.join(self._cert_dir, f"{domain}.crt")
|
cache_name = self._safe_cache_name(domain)
|
||||||
key_file = os.path.join(self._cert_dir, f"{domain}.key")
|
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)
|
ca_pem = self._ca_cert.public_bytes(serialization.Encoding.PEM)
|
||||||
with open(cert_file, "wb") as f:
|
with open(cert_file, "wb") as f:
|
||||||
@@ -122,23 +138,60 @@ class MITMCertManager:
|
|||||||
return self._ctx_cache[domain]
|
return self._ctx_cache[domain]
|
||||||
|
|
||||||
def _generate_domain_cert(self, domain: str):
|
def _generate_domain_cert(self, domain: str):
|
||||||
|
normalized_name, san_entries = self._build_subject_alt_names(domain)
|
||||||
key = rsa.generate_private_key(
|
key = rsa.generate_private_key(
|
||||||
public_exponent=65537, key_size=2048
|
public_exponent=65537, key_size=2048
|
||||||
)
|
)
|
||||||
|
public_key = key.public_key()
|
||||||
subject = x509.Name([
|
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)
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
cert = (
|
cert = (
|
||||||
x509.CertificateBuilder()
|
x509.CertificateBuilder()
|
||||||
.subject_name(subject)
|
.subject_name(subject)
|
||||||
.issuer_name(self._ca_cert.subject)
|
.issuer_name(self._ca_cert.subject)
|
||||||
.public_key(key.public_key())
|
.public_key(public_key)
|
||||||
.serial_number(x509.random_serial_number())
|
.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=365))
|
.not_valid_after(now + datetime.timedelta(days=90))
|
||||||
.add_extension(
|
.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,
|
critical=False,
|
||||||
)
|
)
|
||||||
.sign(self._ca_key, hashes.SHA256())
|
.sign(self._ca_key, hashes.SHA256())
|
||||||
@@ -151,3 +204,17 @@ class MITMCertManager:
|
|||||||
)
|
)
|
||||||
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
|
||||||
return key_pem, cert_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:
|
Supports:
|
||||||
- CONNECT method → WebSocket tunnel (modes 1-3) or MITM relay (apps_script)
|
- 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)
|
- 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 asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -104,6 +107,8 @@ class ProxyServer:
|
|||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict):
|
||||||
self.host = config.get("listen_host", "127.0.0.1")
|
self.host = config.get("listen_host", "127.0.0.1")
|
||||||
self.port = config.get("listen_port", 8080)
|
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.mode = config.get("mode", "domain_fronting")
|
||||||
self.fronter = DomainFronter(config)
|
self.fronter = DomainFronter(config)
|
||||||
self.mitm = None
|
self.mitm = None
|
||||||
@@ -117,6 +122,11 @@ class ProxyServer:
|
|||||||
# hosts override — DNS fake-map: domain/suffix → IP
|
# hosts override — DNS fake-map: domain/suffix → IP
|
||||||
# Checked before any real DNS lookup; supports exact and suffix matching.
|
# Checked before any real DNS lookup; supports exact and suffix matching.
|
||||||
self._hosts: dict[str, str] = config.get("hosts", {})
|
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":
|
if self.mode == "apps_script":
|
||||||
try:
|
try:
|
||||||
@@ -128,13 +138,33 @@ class ProxyServer:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
srv = await asyncio.start_server(self._on_client, self.host, self.port)
|
servers = []
|
||||||
log.info(
|
try:
|
||||||
"Listening on %s:%d — configure your browser HTTP proxy to this address",
|
http_srv = await asyncio.start_server(self._on_client, self.host, self.port)
|
||||||
self.host, self.port,
|
servers.append(http_srv)
|
||||||
)
|
log.info(
|
||||||
async with srv:
|
"Listening on %s:%d — configure your browser HTTP proxy to this address",
|
||||||
await srv.serve_forever()
|
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 ────────────────────────────────────────────
|
# ── client handler ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -176,19 +206,79 @@ class ProxyServer:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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) ────────────────────────────────
|
# ── CONNECT (HTTPS tunnelling) ────────────────────────────────
|
||||||
|
|
||||||
async def _do_connect(self, target: str, reader, writer):
|
async def _do_connect(self, target: str, reader, writer):
|
||||||
host, _, port = target.rpartition(":")
|
host, port = self._split_host_port(target, default_port=443)
|
||||||
port = int(port) if port else 443
|
await self._handle_connect_target(
|
||||||
if not host:
|
host,
|
||||||
host, port = target, 443
|
port,
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
protocol="CONNECT",
|
||||||
|
ready_cb=lambda: self._send_http_connect_ok(writer),
|
||||||
|
)
|
||||||
|
|
||||||
log.info("CONNECT → %s:%d", host, port)
|
async def _handle_connect_target(self, host: str, port: int, reader, writer,
|
||||||
|
*, protocol: str, ready_cb):
|
||||||
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
log.info("%s → %s:%d", protocol, host, port)
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
|
await ready_cb()
|
||||||
if self.mode == "apps_script":
|
if self.mode == "apps_script":
|
||||||
override_ip = self._sni_rewrite_ip(host)
|
override_ip = self._sni_rewrite_ip(host)
|
||||||
if override_ip:
|
if override_ip:
|
||||||
@@ -199,14 +289,54 @@ class ProxyServer:
|
|||||||
host, override_ip, self.fronter.sni_host)
|
host, override_ip, self.fronter.sni_host)
|
||||||
await self._do_sni_rewrite_tunnel(host, port, reader, writer,
|
await self._do_sni_rewrite_tunnel(host, port, reader, writer,
|
||||||
connect_ip=override_ip)
|
connect_ip=override_ip)
|
||||||
elif self._is_google_domain(host):
|
elif self._should_direct_connect(host):
|
||||||
log.info("Direct tunnel → %s (Google domain, skipping relay)", host)
|
log.info("Direct tunnel → %s (skipping relay)", host)
|
||||||
await self._do_direct_tunnel(host, port, reader, writer)
|
await self._do_direct_tunnel(host, port, reader, writer)
|
||||||
else:
|
else:
|
||||||
await self._do_mitm_connect(host, port, reader, writer)
|
await self._do_mitm_connect(host, port, reader, writer)
|
||||||
else:
|
else:
|
||||||
await self.fronter.tunnel(host, port, reader, writer)
|
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) ─────────────────────────────────
|
# ── Hosts override (fake DNS) ─────────────────────────────────
|
||||||
|
|
||||||
# Built-in list of domains that must be reached via Google's frontend IP
|
# Built-in list of domains that must be reached via Google's frontend IP
|
||||||
@@ -290,6 +420,16 @@ class ProxyServer:
|
|||||||
return True
|
return True
|
||||||
return False
|
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) ───────────────────────────────────
|
# ── Direct tunnel (no MITM) ───────────────────────────────────
|
||||||
|
|
||||||
async def _do_direct_tunnel(self, host: str, port: int,
|
async def _do_direct_tunnel(self, host: str, port: int,
|
||||||
|
|||||||
Reference in New Issue
Block a user