diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 00000000..e18d42bd --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,275 @@ +name: Android Build + +on: + push: + branches: [ master, main, 'claude/**' ] + pull_request: + branches: [ master, main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install NDK and CMake + run: | + sdkmanager --install "ndk;25.1.8937393" "cmake;3.22.1" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Create local.properties + run: | + echo "sdk.dir=$ANDROID_HOME" > local.properties + echo "ndk.dir=$ANDROID_NDK_HOME" >> local.properties + + - name: Create dummy google-services.json + run: | + mkdir -p app + cat > app/google-services.json << 'EOF' + { + "project_info": { + "project_number": "000000000000", + "project_id": "dummy-project", + "storage_bucket": "dummy-project.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000", + "android_client_info": { + "package_name": "org.proxydroid" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "dummy-api-key" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" + } + EOF + + - name: Build Debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk + retention-days: 7 + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Create local.properties + run: echo "sdk.dir=$ANDROID_HOME" > local.properties + + - name: Create dummy google-services.json + run: | + mkdir -p app + cat > app/google-services.json << 'EOF' + { + "project_info": { + "project_number": "000000000000", + "project_id": "dummy-project", + "storage_bucket": "dummy-project.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000", + "android_client_info": { + "package_name": "org.proxydroid" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "dummy-api-key" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" + } + EOF + + - name: Run Lint + run: ./gradlew lint --stacktrace + continue-on-error: true + + - name: Upload Lint Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: lint-results + path: app/build/reports/lint-results*.html + retention-days: 7 + + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install NDK and CMake + run: | + sdkmanager --install "ndk;25.1.8937393" "cmake;3.22.1" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Create local.properties + run: | + echo "sdk.dir=$ANDROID_HOME" > local.properties + echo "ndk.dir=$ANDROID_NDK_HOME" >> local.properties + + - name: Create dummy google-services.json + run: | + mkdir -p app + cat > app/google-services.json << 'EOF' + { + "project_info": { + "project_number": "000000000000", + "project_id": "dummy-project", + "storage_bucket": "dummy-project.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000", + "android_client_info": { + "package_name": "org.proxydroid" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "dummy-api-key" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" + } + EOF + + - name: Run Unit Tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: | + app/build/reports/tests/ + app/build/test-results/ + retention-days: 7 + + - name: Test Summary + if: always() + run: | + echo "=== Test Summary ===" + find app/build -name "TEST-*.xml" -type f 2>/dev/null | while read f; do + echo "--- $f ---" + cat "$f" + done || echo "No test XML results found" + echo "" + echo "=== Test Report ===" + if [ -f "app/build/reports/tests/testDebugUnitTest/index.html" ]; then + echo "Test report generated successfully" + else + echo "No HTML test report found" + find app/build -name "*.html" -type f 2>/dev/null | head -20 + fi diff --git a/app/build.gradle b/app/build.gradle index e4ac84b9..b045f10b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,19 @@ -apply plugin: 'com.android.application' -apply plugin: 'com.google.gms.google-services' -apply plugin: 'io.fabric' +plugins { + id 'com.android.application' + id 'com.google.gms.google-services' + id 'com.google.firebase.crashlytics' +} android { - compileSdkVersion 29 - buildToolsVersion "29.0.0" + namespace 'org.proxydroid' + compileSdk 33 + ndkVersion "25.1.8937393" defaultConfig { applicationId "org.proxydroid" - minSdkVersion 16 - targetSdkVersion 29 - versionCode 72 - versionName "3.2.0" + minSdk 21 + targetSdk 33 + versionCode 73 + versionName "3.3.0" ndk { // Specifies the ABI configurations of your native @@ -25,20 +28,19 @@ android { } } compileOptions { - sourceCompatibility = 1.8 - targetCompatibility = 1.8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } externalNativeBuild { cmake { - version '3.10.2' path "src/main/cpp/CMakeLists.txt" } } } configurations { - compile.exclude group: "junit", module: "junit" + implementation.exclude group: "junit", module: "junit" } dependencies { @@ -50,5 +52,9 @@ dependencies { implementation 'com.google.firebase:firebase-core:17.2.1' implementation 'com.google.firebase:firebase-ads:18.3.0' implementation 'com.google.firebase:firebase-analytics:17.2.1' - implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' + implementation 'com.google.firebase:firebase-crashlytics:18.4.3' + + // Test dependencies + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:4.11.0' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c87214ce..d19a2c33 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,14 +1,14 @@ - + - + + + android:name=".ProxyDroid" + android:label="@string/app_name" + android:exported="true"> @@ -46,14 +47,29 @@ + android:enabled="true" + android:exported="false" /> + + + + + + - + - + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 8bf1bf5b..40518ee2 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -3,3 +3,4 @@ cmake_minimum_required(VERSION 3.4.1) add_subdirectory(exec) add_subdirectory(libevent) add_subdirectory(redsocks) +add_subdirectory(tun2socks) diff --git a/app/src/main/cpp/libevent/evutil_rand.c b/app/src/main/cpp/libevent/evutil_rand.c index 284e230e..6cf94f97 100644 --- a/app/src/main/cpp/libevent/evutil_rand.c +++ b/app/src/main/cpp/libevent/evutil_rand.c @@ -153,7 +153,9 @@ evutil_secure_rng_get_bytes(void *buf, size_t n) void evutil_secure_rng_add_bytes(const char *buf, size_t n) { +#ifndef _EVENT_HAVE_ARC4RANDOM arc4random_addrandom((unsigned char*)buf, n>(size_t)INT_MAX ? INT_MAX : (int)n); +#endif } diff --git a/app/src/main/cpp/tun2socks/CMakeLists.txt b/app/src/main/cpp/tun2socks/CMakeLists.txt new file mode 100644 index 00000000..ae8a3014 --- /dev/null +++ b/app/src/main/cpp/tun2socks/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.4.1) + +add_library(tun2socks SHARED + tun2socks_jni.cpp + core/tun2socks.c +) + +target_include_directories(tun2socks PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/core +) + +target_link_libraries(tun2socks + android + log +) + +# Enable C++11 for JNI wrapper +set_target_properties(tun2socks PROPERTIES + CXX_STANDARD 11 + CXX_STANDARD_REQUIRED ON +) + +# C99 for core implementation +set_source_files_properties(core/tun2socks.c PROPERTIES + COMPILE_FLAGS "-std=c99" +) diff --git a/app/src/main/cpp/tun2socks/core/tun2socks.c b/app/src/main/cpp/tun2socks/core/tun2socks.c new file mode 100644 index 00000000..eb3fb770 --- /dev/null +++ b/app/src/main/cpp/tun2socks/core/tun2socks.c @@ -0,0 +1,911 @@ +/* + * tun2socks - Convert TUN traffic to SOCKS proxy + * Main implementation + */ + +#include "tun2socks.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __ANDROID__ +#include +#define LOG_TAG "tun2socks" +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#else +#define LOGD(...) fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n") +#define LOGI(...) fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n") +#define LOGW(...) fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n") +#define LOGE(...) fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n") +#endif + +// Parse SOCKS5 URL: socks5://[user:pass@]host:port +static int parse_socks_url(const char *url, socks5_config_t *config) { + memset(config, 0, sizeof(socks5_config_t)); + + const char *p = url; + + // Skip protocol + if (strncmp(p, "socks5://", 9) == 0) { + p += 9; + } else if (strncmp(p, "socks://", 8) == 0) { + p += 8; + } + + // Check for auth + const char *at = strchr(p, '@'); + if (at != NULL) { + const char *colon = strchr(p, ':'); + if (colon != NULL && colon < at) { + size_t user_len = colon - p; + size_t pass_len = at - colon - 1; + if (user_len < sizeof(config->username) && pass_len < sizeof(config->password)) { + strncpy(config->username, p, user_len); + strncpy(config->password, colon + 1, pass_len); + config->auth_required = true; + } + } + p = at + 1; + } + + // Parse host:port + const char *colon = strrchr(p, ':'); + if (colon != NULL) { + size_t host_len = colon - p; + if (host_len < sizeof(config->host)) { + strncpy(config->host, p, host_len); + config->port = atoi(colon + 1); + } + } else { + strncpy(config->host, p, sizeof(config->host) - 1); + config->port = 1080; + } + + return 0; +} + +tun2socks_ctx_t *tun2socks_create(int tun_fd, int mtu, const char *tun_addr, + const char *proxy_url, const char *dns_addr) { + tun2socks_ctx_t *ctx = calloc(1, sizeof(tun2socks_ctx_t)); + if (ctx == NULL) { + return NULL; + } + + ctx->tun_fd = tun_fd; + ctx->mtu = mtu > 0 ? mtu : TUN2SOCKS_MTU; + + inet_pton(AF_INET, tun_addr, &ctx->tun_addr); + ctx->tun_netmask = htonl(0xFFFFFF00); // /24 + + if (dns_addr != NULL) { + strncpy(ctx->dns_addr, dns_addr, sizeof(ctx->dns_addr) - 1); + } + + parse_socks_url(proxy_url, &ctx->socks_config); + + ctx->running = false; + ctx->tcp_connections = NULL; + ctx->udp_sessions = NULL; + + LOGI("tun2socks created: tun_fd=%d, mtu=%d, proxy=%s:%d", + tun_fd, ctx->mtu, ctx->socks_config.host, ctx->socks_config.port); + + return ctx; +} + +void tun2socks_destroy(tun2socks_ctx_t *ctx) { + if (ctx == NULL) return; + + ctx->running = false; + + // Clean up TCP connections + tcp_connection_t *conn = ctx->tcp_connections; + while (conn != NULL) { + tcp_connection_t *next = conn->next; + if (conn->socks_fd >= 0) { + close(conn->socks_fd); + } + if (conn->send_buffer) free(conn->send_buffer); + if (conn->recv_buffer) free(conn->recv_buffer); + free(conn); + conn = next; + } + + // Clean up UDP sessions + udp_session_t *sess = ctx->udp_sessions; + while (sess != NULL) { + udp_session_t *next = sess->next; + if (sess->socks_fd >= 0) { + close(sess->socks_fd); + } + free(sess); + sess = next; + } + + free(ctx); +} + +void tun2socks_stop(tun2socks_ctx_t *ctx) { + if (ctx != NULL) { + ctx->running = false; + } +} + +// Calculate IP header checksum +uint16_t ip_checksum(void *data, size_t len) { + uint32_t sum = 0; + uint16_t *ptr = (uint16_t *)data; + + while (len > 1) { + sum += *ptr++; + len -= 2; + } + + if (len == 1) { + sum += *(uint8_t *)ptr; + } + + while (sum >> 16) { + sum = (sum & 0xFFFF) + (sum >> 16); + } + + return ~sum; +} + +// Calculate TCP checksum with pseudo-header +uint16_t tcp_checksum(ip_header_t *ip, tcp_header_t *tcp, uint8_t *data, size_t data_len) { + uint32_t sum = 0; + + // Pseudo header + sum += (ip->src_addr >> 16) & 0xFFFF; + sum += ip->src_addr & 0xFFFF; + sum += (ip->dst_addr >> 16) & 0xFFFF; + sum += ip->dst_addr & 0xFFFF; + sum += htons(IPPROTO_TCP); + + size_t tcp_len = ((tcp->data_offset >> 4) * 4) + data_len; + sum += htons(tcp_len); + + // TCP header (with checksum field set to 0) + uint16_t saved_checksum = tcp->checksum; + tcp->checksum = 0; + + uint16_t *ptr = (uint16_t *)tcp; + size_t header_len = (tcp->data_offset >> 4) * 4; + + for (size_t i = 0; i < header_len / 2; i++) { + sum += ptr[i]; + } + + // TCP data + ptr = (uint16_t *)data; + size_t remaining = data_len; + while (remaining > 1) { + sum += *ptr++; + remaining -= 2; + } + if (remaining == 1) { + sum += *(uint8_t *)ptr; + } + + tcp->checksum = saved_checksum; + + while (sum >> 16) { + sum = (sum & 0xFFFF) + (sum >> 16); + } + + return ~sum; +} + +// Calculate UDP checksum with pseudo-header +uint16_t udp_checksum(ip_header_t *ip, udp_header_t *udp, uint8_t *data, size_t data_len) { + uint32_t sum = 0; + + // Pseudo header + sum += (ip->src_addr >> 16) & 0xFFFF; + sum += ip->src_addr & 0xFFFF; + sum += (ip->dst_addr >> 16) & 0xFFFF; + sum += ip->dst_addr & 0xFFFF; + sum += htons(IPPROTO_UDP); + sum += udp->length; + + // UDP header + uint16_t saved_checksum = udp->checksum; + udp->checksum = 0; + + uint16_t *ptr = (uint16_t *)udp; + for (int i = 0; i < 4; i++) { + sum += ptr[i]; + } + + // UDP data + ptr = (uint16_t *)data; + size_t remaining = data_len; + while (remaining > 1) { + sum += *ptr++; + remaining -= 2; + } + if (remaining == 1) { + sum += *(uint8_t *)ptr; + } + + udp->checksum = saved_checksum; + + while (sum >> 16) { + sum = (sum & 0xFFFF) + (sum >> 16); + } + + return ~sum; +} + +tcp_connection_t *find_tcp_connection(tun2socks_ctx_t *ctx, uint32_t src, uint16_t sport, + uint32_t dst, uint16_t dport) { + tcp_connection_t *conn = ctx->tcp_connections; + while (conn != NULL) { + if (conn->src_addr == src && conn->src_port == sport && + conn->dst_addr == dst && conn->dst_port == dport) { + return conn; + } + conn = conn->next; + } + return NULL; +} + +tcp_connection_t *create_tcp_connection(tun2socks_ctx_t *ctx, uint32_t src, uint16_t sport, + uint32_t dst, uint16_t dport) { + tcp_connection_t *conn = calloc(1, sizeof(tcp_connection_t)); + if (conn == NULL) return NULL; + + conn->src_addr = src; + conn->src_port = sport; + conn->dst_addr = dst; + conn->dst_port = dport; + conn->state = TCP_STATE_CLOSED; + conn->socks_fd = -1; + conn->local_window = 65535; + conn->last_activity = time(NULL); + + conn->send_buffer_cap = TUN2SOCKS_BUFFER_SIZE; + conn->send_buffer = malloc(conn->send_buffer_cap); + conn->recv_buffer_cap = TUN2SOCKS_BUFFER_SIZE; + conn->recv_buffer = malloc(conn->recv_buffer_cap); + + if (conn->send_buffer == NULL || conn->recv_buffer == NULL) { + if (conn->send_buffer) free(conn->send_buffer); + if (conn->recv_buffer) free(conn->recv_buffer); + free(conn); + return NULL; + } + + // Add to list + conn->next = ctx->tcp_connections; + conn->prev = NULL; + if (ctx->tcp_connections != NULL) { + ctx->tcp_connections->prev = conn; + } + ctx->tcp_connections = conn; + + return conn; +} + +void destroy_tcp_connection(tun2socks_ctx_t *ctx, tcp_connection_t *conn) { + if (conn == NULL) return; + + // Remove from list + if (conn->prev != NULL) { + conn->prev->next = conn->next; + } else { + ctx->tcp_connections = conn->next; + } + if (conn->next != NULL) { + conn->next->prev = conn->prev; + } + + if (conn->socks_fd >= 0) { + close(conn->socks_fd); + } + if (conn->send_buffer) free(conn->send_buffer); + if (conn->recv_buffer) free(conn->recv_buffer); + free(conn); +} + +// Connect to SOCKS5 proxy +int socks5_connect(socks5_config_t *config, const char *dest_host, int dest_port) { + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) { + LOGE("Failed to create socket: %s", strerror(errno)); + return -1; + } + + // Set non-blocking for connect + int flags = fcntl(sock, F_GETFL, 0); + fcntl(sock, F_SETFL, flags | O_NONBLOCK); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(config->port); + inet_pton(AF_INET, config->host, &addr.sin_addr); + + int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr)); + if (ret < 0 && errno != EINPROGRESS) { + LOGE("Failed to connect to SOCKS server: %s", strerror(errno)); + close(sock); + return -1; + } + + // Wait for connection + struct pollfd pfd; + pfd.fd = sock; + pfd.events = POLLOUT; + ret = poll(&pfd, 1, TUN2SOCKS_CONNECT_TIMEOUT * 1000); + if (ret <= 0) { + LOGE("SOCKS connect timeout"); + close(sock); + return -1; + } + + // Check connection result + int error = 0; + socklen_t len = sizeof(error); + getsockopt(sock, SOL_SOCKET, SO_ERROR, &error, &len); + if (error != 0) { + LOGE("SOCKS connect failed: %s", strerror(error)); + close(sock); + return -1; + } + + // Set back to blocking + fcntl(sock, F_SETFL, flags); + + // SOCKS5 handshake + uint8_t handshake[4]; + handshake[0] = SOCKS5_VERSION; + + if (config->auth_required) { + handshake[1] = 2; // 2 methods + handshake[2] = SOCKS5_AUTH_NONE; + handshake[3] = SOCKS5_AUTH_PASSWORD; + write(sock, handshake, 4); + } else { + handshake[1] = 1; // 1 method + handshake[2] = SOCKS5_AUTH_NONE; + write(sock, handshake, 3); + } + + uint8_t response[2]; + if (read(sock, response, 2) != 2 || response[0] != SOCKS5_VERSION) { + LOGE("Invalid SOCKS5 handshake response"); + close(sock); + return -1; + } + + // Handle authentication + if (response[1] == SOCKS5_AUTH_PASSWORD) { + if (!config->auth_required) { + LOGE("Server requires auth but none provided"); + close(sock); + return -1; + } + + size_t user_len = strlen(config->username); + size_t pass_len = strlen(config->password); + uint8_t auth[515]; + auth[0] = 0x01; // Auth version + auth[1] = user_len; + memcpy(auth + 2, config->username, user_len); + auth[2 + user_len] = pass_len; + memcpy(auth + 3 + user_len, config->password, pass_len); + + write(sock, auth, 3 + user_len + pass_len); + + uint8_t auth_resp[2]; + if (read(sock, auth_resp, 2) != 2 || auth_resp[1] != 0) { + LOGE("SOCKS5 authentication failed"); + close(sock); + return -1; + } + } else if (response[1] != SOCKS5_AUTH_NONE) { + LOGE("Unsupported SOCKS5 auth method: %d", response[1]); + close(sock); + return -1; + } + + // Send connect request + uint8_t request[512]; + size_t req_len = 0; + request[req_len++] = SOCKS5_VERSION; + request[req_len++] = SOCKS5_CMD_CONNECT; + request[req_len++] = 0x00; // Reserved + + // Check if dest_host is IP or domain + struct in_addr ip; + if (inet_pton(AF_INET, dest_host, &ip) == 1) { + request[req_len++] = SOCKS5_ATYP_IPV4; + memcpy(request + req_len, &ip.s_addr, 4); + req_len += 4; + } else { + size_t host_len = strlen(dest_host); + request[req_len++] = SOCKS5_ATYP_DOMAIN; + request[req_len++] = host_len; + memcpy(request + req_len, dest_host, host_len); + req_len += host_len; + } + + request[req_len++] = (dest_port >> 8) & 0xFF; + request[req_len++] = dest_port & 0xFF; + + write(sock, request, req_len); + + // Read response + uint8_t conn_resp[4]; + if (read(sock, conn_resp, 4) != 4) { + LOGE("Failed to read SOCKS5 connect response"); + close(sock); + return -1; + } + + if (conn_resp[0] != SOCKS5_VERSION || conn_resp[1] != SOCKS5_REP_SUCCESS) { + LOGE("SOCKS5 connect failed: %d", conn_resp[1]); + close(sock); + return -1; + } + + // Skip bound address + if (conn_resp[3] == SOCKS5_ATYP_IPV4) { + uint8_t skip[6]; + read(sock, skip, 6); + } else if (conn_resp[3] == SOCKS5_ATYP_DOMAIN) { + uint8_t len; + read(sock, &len, 1); + uint8_t skip[258]; + read(sock, skip, len + 2); + } else if (conn_resp[3] == SOCKS5_ATYP_IPV6) { + uint8_t skip[18]; + read(sock, skip, 18); + } + + LOGD("SOCKS5 connected to %s:%d", dest_host, dest_port); + return sock; +} + +// Send TCP packet to TUN +int send_tcp_packet(tun2socks_ctx_t *ctx, tcp_connection_t *conn, uint8_t flags, + uint8_t *data, size_t data_len) { + uint8_t packet[TUN2SOCKS_MTU]; + size_t packet_len = 0; + + // IP header + ip_header_t *ip = (ip_header_t *)packet; + ip->version_ihl = 0x45; // IPv4, 20 bytes header + ip->tos = 0; + ip->identification = htons(rand() & 0xFFFF); + ip->flags_offset = htons(0x4000); // Don't fragment + ip->ttl = 64; + ip->protocol = IPPROTO_TCP; + ip->src_addr = conn->dst_addr; // Swap src/dst + ip->dst_addr = conn->src_addr; + ip->checksum = 0; + + packet_len = 20; + + // TCP header + tcp_header_t *tcp = (tcp_header_t *)(packet + packet_len); + tcp->src_port = conn->dst_port; // Swap src/dst + tcp->dst_port = conn->src_port; + tcp->seq = htonl(conn->local_seq); + tcp->ack = htonl(conn->local_ack); + tcp->data_offset = 0x50; // 20 bytes header + tcp->flags = flags; + tcp->window = htons(conn->local_window); + tcp->checksum = 0; + tcp->urgent_ptr = 0; + + packet_len += 20; + + // Data + if (data != NULL && data_len > 0) { + memcpy(packet + packet_len, data, data_len); + packet_len += data_len; + } + + // Set lengths + ip->total_length = htons(packet_len); + + // Calculate checksums + ip->checksum = ip_checksum(ip, 20); + tcp->checksum = tcp_checksum(ip, tcp, data, data_len); + + // Write to TUN + ssize_t written = write(ctx->tun_fd, packet, packet_len); + if (written < 0) { + LOGE("Failed to write to TUN: %s", strerror(errno)); + return -1; + } + + ctx->packets_out++; + ctx->bytes_out += written; + + return 0; +} + +// Send TCP RST +int send_tcp_rst(tun2socks_ctx_t *ctx, ip_header_t *orig_ip, tcp_header_t *orig_tcp) { + uint8_t packet[40]; + + // IP header + ip_header_t *ip = (ip_header_t *)packet; + ip->version_ihl = 0x45; + ip->tos = 0; + ip->total_length = htons(40); + ip->identification = htons(rand() & 0xFFFF); + ip->flags_offset = htons(0x4000); + ip->ttl = 64; + ip->protocol = IPPROTO_TCP; + ip->src_addr = orig_ip->dst_addr; + ip->dst_addr = orig_ip->src_addr; + ip->checksum = 0; + + // TCP header + tcp_header_t *tcp = (tcp_header_t *)(packet + 20); + tcp->src_port = orig_tcp->dst_port; + tcp->dst_port = orig_tcp->src_port; + tcp->seq = orig_tcp->ack; + tcp->ack = htonl(ntohl(orig_tcp->seq) + 1); + tcp->data_offset = 0x50; + tcp->flags = TCP_FLAG_RST | TCP_FLAG_ACK; + tcp->window = 0; + tcp->checksum = 0; + tcp->urgent_ptr = 0; + + ip->checksum = ip_checksum(ip, 20); + tcp->checksum = tcp_checksum(ip, tcp, NULL, 0); + + write(ctx->tun_fd, packet, 40); + return 0; +} + +// Process incoming TCP packet from TUN +int process_tcp_packet(tun2socks_ctx_t *ctx, ip_header_t *ip, uint8_t *payload, size_t payload_len) { + if (payload_len < 20) { + return -1; + } + + tcp_header_t *tcp = (tcp_header_t *)payload; + uint8_t header_len = (tcp->data_offset >> 4) * 4; + uint8_t *data = payload + header_len; + size_t data_len = payload_len - header_len; + + uint16_t src_port = ntohs(tcp->src_port); + uint16_t dst_port = ntohs(tcp->dst_port); + uint32_t seq = ntohl(tcp->seq); + uint32_t ack = ntohl(tcp->ack); + uint8_t flags = tcp->flags; + + // Find or create connection + tcp_connection_t *conn = find_tcp_connection(ctx, ip->src_addr, src_port, + ip->dst_addr, dst_port); + + // Handle SYN - new connection + if (flags & TCP_FLAG_SYN) { + if (conn != NULL) { + // Reset existing connection + destroy_tcp_connection(ctx, conn); + } + + conn = create_tcp_connection(ctx, ip->src_addr, src_port, + ip->dst_addr, dst_port); + if (conn == NULL) { + send_tcp_rst(ctx, ip, tcp); + return -1; + } + + // Connect to SOCKS proxy + char dst_str[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &ip->dst_addr, dst_str, sizeof(dst_str)); + + conn->socks_fd = socks5_connect(&ctx->socks_config, dst_str, dst_port); + if (conn->socks_fd < 0) { + LOGW("Failed to connect to %s:%d via SOCKS", dst_str, dst_port); + send_tcp_rst(ctx, ip, tcp); + destroy_tcp_connection(ctx, conn); + return -1; + } + + // Set non-blocking + int flags_fd = fcntl(conn->socks_fd, F_GETFL, 0); + fcntl(conn->socks_fd, F_SETFL, flags_fd | O_NONBLOCK); + + conn->socks_connected = true; + conn->remote_seq = seq; + conn->local_seq = rand(); + conn->local_ack = seq + 1; + conn->remote_window = ntohs(tcp->window); + conn->state = TCP_STATE_SYN_RECEIVED; + + // Send SYN-ACK + send_tcp_packet(ctx, conn, TCP_FLAG_SYN | TCP_FLAG_ACK, NULL, 0); + conn->local_seq++; + + LOGD("TCP SYN: %s:%d", dst_str, dst_port); + return 0; + } + + if (conn == NULL) { + // No connection for this packet + send_tcp_rst(ctx, ip, tcp); + return -1; + } + + conn->last_activity = time(NULL); + + // Handle ACK + if (flags & TCP_FLAG_ACK) { + if (conn->state == TCP_STATE_SYN_RECEIVED) { + conn->state = TCP_STATE_ESTABLISHED; + LOGD("TCP ESTABLISHED"); + } + } + + // Handle data + if (data_len > 0 && conn->state == TCP_STATE_ESTABLISHED) { + // Forward to SOCKS + if (conn->socks_fd >= 0) { + ssize_t sent = write(conn->socks_fd, data, data_len); + if (sent < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + LOGE("Failed to send to SOCKS: %s", strerror(errno)); + send_tcp_rst(ctx, ip, tcp); + destroy_tcp_connection(ctx, conn); + return -1; + } + } + + // Update ack + conn->local_ack = seq + data_len; + + // Send ACK + send_tcp_packet(ctx, conn, TCP_FLAG_ACK, NULL, 0); + } + + // Handle FIN + if (flags & TCP_FLAG_FIN) { + conn->local_ack = seq + 1; + + if (conn->state == TCP_STATE_ESTABLISHED) { + conn->state = TCP_STATE_CLOSE_WAIT; + // Send ACK + send_tcp_packet(ctx, conn, TCP_FLAG_ACK, NULL, 0); + // Send FIN + send_tcp_packet(ctx, conn, TCP_FLAG_FIN | TCP_FLAG_ACK, NULL, 0); + conn->local_seq++; + conn->state = TCP_STATE_LAST_ACK; + } else if (conn->state == TCP_STATE_FIN_WAIT_1 || conn->state == TCP_STATE_FIN_WAIT_2) { + send_tcp_packet(ctx, conn, TCP_FLAG_ACK, NULL, 0); + conn->state = TCP_STATE_TIME_WAIT; + } + + if (conn->state == TCP_STATE_LAST_ACK || conn->state == TCP_STATE_TIME_WAIT) { + destroy_tcp_connection(ctx, conn); + } + } + + // Handle RST + if (flags & TCP_FLAG_RST) { + destroy_tcp_connection(ctx, conn); + } + + return 0; +} + +// Process incoming UDP packet +int process_udp_packet(tun2socks_ctx_t *ctx, ip_header_t *ip, uint8_t *payload, size_t payload_len) { + if (payload_len < 8) { + return -1; + } + + udp_header_t *udp = (udp_header_t *)payload; + uint8_t *data = payload + 8; + size_t data_len = payload_len - 8; + + uint16_t src_port = ntohs(udp->src_port); + uint16_t dst_port = ntohs(udp->dst_port); + + char dst_str[INET_ADDRSTRLEN]; + inet_ntop(AF_INET, &ip->dst_addr, dst_str, sizeof(dst_str)); + + // For DNS (port 53), forward directly or through SOCKS UDP associate + // For simplicity, we'll forward DNS via UDP socket directly to configured DNS + if (dst_port == 53 && ctx->dns_addr[0] != '\0') { + int sock = socket(AF_INET, SOCK_DGRAM, 0); + if (sock < 0) { + return -1; + } + + struct sockaddr_in dns_addr; + memset(&dns_addr, 0, sizeof(dns_addr)); + dns_addr.sin_family = AF_INET; + dns_addr.sin_port = htons(53); + inet_pton(AF_INET, ctx->dns_addr, &dns_addr.sin_addr); + + sendto(sock, data, data_len, 0, (struct sockaddr *)&dns_addr, sizeof(dns_addr)); + + // Wait for response + struct pollfd pfd; + pfd.fd = sock; + pfd.events = POLLIN; + if (poll(&pfd, 1, 5000) > 0) { + uint8_t response[512]; + ssize_t resp_len = recv(sock, response, sizeof(response), 0); + if (resp_len > 0) { + // Send response back to TUN + uint8_t packet[TUN2SOCKS_MTU]; + + ip_header_t *resp_ip = (ip_header_t *)packet; + resp_ip->version_ihl = 0x45; + resp_ip->tos = 0; + resp_ip->identification = htons(rand() & 0xFFFF); + resp_ip->flags_offset = 0; + resp_ip->ttl = 64; + resp_ip->protocol = IPPROTO_UDP; + resp_ip->src_addr = ip->dst_addr; + resp_ip->dst_addr = ip->src_addr; + resp_ip->checksum = 0; + + udp_header_t *resp_udp = (udp_header_t *)(packet + 20); + resp_udp->src_port = udp->dst_port; + resp_udp->dst_port = udp->src_port; + resp_udp->length = htons(8 + resp_len); + resp_udp->checksum = 0; + + memcpy(packet + 28, response, resp_len); + + size_t packet_len = 28 + resp_len; + resp_ip->total_length = htons(packet_len); + resp_ip->checksum = ip_checksum(resp_ip, 20); + resp_udp->checksum = udp_checksum(resp_ip, resp_udp, response, resp_len); + + write(ctx->tun_fd, packet, packet_len); + } + } + + close(sock); + } + + return 0; +} + +// Process IP packet from TUN +int process_ip_packet(tun2socks_ctx_t *ctx, uint8_t *packet, size_t len) { + if (len < 20) { + return -1; + } + + ip_header_t *ip = (ip_header_t *)packet; + uint8_t version = (ip->version_ihl >> 4) & 0x0F; + uint8_t ihl = (ip->version_ihl & 0x0F) * 4; + + if (version != 4) { + // Only handle IPv4 + return -1; + } + + if (len < ihl) { + return -1; + } + + uint8_t *payload = packet + ihl; + size_t payload_len = len - ihl; + + ctx->packets_in++; + ctx->bytes_in += len; + + switch (ip->protocol) { + case IPPROTO_TCP: + return process_tcp_packet(ctx, ip, payload, payload_len); + case IPPROTO_UDP: + return process_udp_packet(ctx, ip, payload, payload_len); + case IPPROTO_ICMP: + // Ignore ICMP for now + return 0; + default: + return -1; + } +} + +// Check for data from SOCKS connections and send to TUN +static void process_socks_data(tun2socks_ctx_t *ctx) { + tcp_connection_t *conn = ctx->tcp_connections; + while (conn != NULL) { + tcp_connection_t *next = conn->next; + + if (conn->socks_fd >= 0 && conn->state == TCP_STATE_ESTABLISHED) { + struct pollfd pfd; + pfd.fd = conn->socks_fd; + pfd.events = POLLIN; + + if (poll(&pfd, 1, 0) > 0 && (pfd.revents & POLLIN)) { + uint8_t buffer[4096]; + ssize_t len = read(conn->socks_fd, buffer, sizeof(buffer)); + + if (len > 0) { + // Send to TUN + send_tcp_packet(ctx, conn, TCP_FLAG_ACK | TCP_FLAG_PSH, buffer, len); + conn->local_seq += len; + conn->last_activity = time(NULL); + } else if (len == 0) { + // Connection closed by remote + send_tcp_packet(ctx, conn, TCP_FLAG_FIN | TCP_FLAG_ACK, NULL, 0); + conn->local_seq++; + conn->state = TCP_STATE_FIN_WAIT_1; + } else if (errno != EAGAIN && errno != EWOULDBLOCK) { + // Error + tcp_connection_t tmp; + tmp.src_addr = conn->dst_addr; + tmp.src_port = conn->dst_port; + tmp.dst_addr = conn->src_addr; + tmp.dst_port = conn->src_port; + // Just close the connection + destroy_tcp_connection(ctx, conn); + } + } + + // Check for timeout + if (time(NULL) - conn->last_activity > TUN2SOCKS_IDLE_TIMEOUT) { + LOGD("Connection timeout"); + destroy_tcp_connection(ctx, conn); + } + } + + conn = next; + } +} + +// Main run loop +int tun2socks_run(tun2socks_ctx_t *ctx) { + ctx->running = true; + + uint8_t buffer[TUN2SOCKS_BUFFER_SIZE]; + struct pollfd pfd; + pfd.fd = ctx->tun_fd; + pfd.events = POLLIN; + + LOGI("tun2socks running"); + + while (ctx->running) { + int ret = poll(&pfd, 1, 100); + + if (ret < 0) { + if (errno == EINTR) continue; + LOGE("poll error: %s", strerror(errno)); + break; + } + + if (ret > 0 && (pfd.revents & POLLIN)) { + ssize_t len = read(ctx->tun_fd, buffer, sizeof(buffer)); + if (len > 0) { + process_ip_packet(ctx, buffer, len); + } else if (len < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { + LOGE("TUN read error: %s", strerror(errno)); + break; + } + } + + // Process data from SOCKS connections + process_socks_data(ctx); + } + + LOGI("tun2socks stopped (packets: in=%lu out=%lu, bytes: in=%lu out=%lu)", + ctx->packets_in, ctx->packets_out, ctx->bytes_in, ctx->bytes_out); + + return 0; +} diff --git a/app/src/main/cpp/tun2socks/core/tun2socks.h b/app/src/main/cpp/tun2socks/core/tun2socks.h new file mode 100644 index 00000000..24738890 --- /dev/null +++ b/app/src/main/cpp/tun2socks/core/tun2socks.h @@ -0,0 +1,214 @@ +/* + * tun2socks - Convert TUN traffic to SOCKS proxy + * Core header file + */ + +#ifndef TUN2SOCKS_H +#define TUN2SOCKS_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Configuration +#define TUN2SOCKS_MTU 1500 +#define TUN2SOCKS_MAX_CONNECTIONS 1024 +#define TUN2SOCKS_BUFFER_SIZE 65536 +#define TUN2SOCKS_CONNECT_TIMEOUT 10 +#define TUN2SOCKS_IDLE_TIMEOUT 300 + +// SOCKS5 constants +#define SOCKS5_VERSION 0x05 +#define SOCKS5_AUTH_NONE 0x00 +#define SOCKS5_AUTH_PASSWORD 0x02 +#define SOCKS5_CMD_CONNECT 0x01 +#define SOCKS5_CMD_UDP_ASSOCIATE 0x03 +#define SOCKS5_ATYP_IPV4 0x01 +#define SOCKS5_ATYP_DOMAIN 0x03 +#define SOCKS5_ATYP_IPV6 0x04 +#define SOCKS5_REP_SUCCESS 0x00 + +// IP protocol numbers (use system definitions if available) +#ifndef IPPROTO_ICMP +#define IPPROTO_ICMP 1 +#endif +#ifndef IPPROTO_TCP +#define IPPROTO_TCP 6 +#endif +#ifndef IPPROTO_UDP +#define IPPROTO_UDP 17 +#endif + +// TCP flags +#define TCP_FLAG_FIN 0x01 +#define TCP_FLAG_SYN 0x02 +#define TCP_FLAG_RST 0x04 +#define TCP_FLAG_PSH 0x08 +#define TCP_FLAG_ACK 0x10 +#define TCP_FLAG_URG 0x20 + +// TCP states +typedef enum { + TCP_STATE_CLOSED, + TCP_STATE_SYN_RECEIVED, + TCP_STATE_ESTABLISHED, + TCP_STATE_FIN_WAIT_1, + TCP_STATE_FIN_WAIT_2, + TCP_STATE_CLOSING, + TCP_STATE_TIME_WAIT, + TCP_STATE_CLOSE_WAIT, + TCP_STATE_LAST_ACK +} tcp_state_t; + +// IP header structure +typedef struct __attribute__((packed)) { + uint8_t version_ihl; + uint8_t tos; + uint16_t total_length; + uint16_t identification; + uint16_t flags_offset; + uint8_t ttl; + uint8_t protocol; + uint16_t checksum; + uint32_t src_addr; + uint32_t dst_addr; +} ip_header_t; + +// TCP header structure +typedef struct __attribute__((packed)) { + uint16_t src_port; + uint16_t dst_port; + uint32_t seq; + uint32_t ack; + uint8_t data_offset; + uint8_t flags; + uint16_t window; + uint16_t checksum; + uint16_t urgent_ptr; +} tcp_header_t; + +// UDP header structure +typedef struct __attribute__((packed)) { + uint16_t src_port; + uint16_t dst_port; + uint16_t length; + uint16_t checksum; +} udp_header_t; + +// SOCKS5 configuration +typedef struct { + char host[256]; + int port; + char username[256]; + char password[256]; + bool auth_required; +} socks5_config_t; + +// TCP connection structure +typedef struct tcp_connection { + uint32_t src_addr; + uint16_t src_port; + uint32_t dst_addr; + uint16_t dst_port; + + tcp_state_t state; + + uint32_t local_seq; + uint32_t local_ack; + uint32_t remote_seq; + uint32_t remote_ack; + + uint16_t local_window; + uint16_t remote_window; + + int socks_fd; + bool socks_connected; + + uint8_t *send_buffer; + size_t send_buffer_len; + size_t send_buffer_cap; + + uint8_t *recv_buffer; + size_t recv_buffer_len; + size_t recv_buffer_cap; + + time_t last_activity; + + struct tcp_connection *next; + struct tcp_connection *prev; +} tcp_connection_t; + +// UDP session structure +typedef struct udp_session { + uint32_t src_addr; + uint16_t src_port; + + int socks_fd; + struct sockaddr_in udp_relay; + + time_t last_activity; + + struct udp_session *next; + struct udp_session *prev; +} udp_session_t; + +// Tun2socks context +typedef struct { + int tun_fd; + int mtu; + uint32_t tun_addr; + uint32_t tun_netmask; + char dns_addr[64]; + + socks5_config_t socks_config; + + tcp_connection_t *tcp_connections; + udp_session_t *udp_sessions; + + volatile bool running; + + // Statistics + uint64_t packets_in; + uint64_t packets_out; + uint64_t bytes_in; + uint64_t bytes_out; +} tun2socks_ctx_t; + +// Function prototypes +tun2socks_ctx_t *tun2socks_create(int tun_fd, int mtu, const char *tun_addr, + const char *proxy_url, const char *dns_addr); +void tun2socks_destroy(tun2socks_ctx_t *ctx); +int tun2socks_run(tun2socks_ctx_t *ctx); +void tun2socks_stop(tun2socks_ctx_t *ctx); + +// Internal functions +int process_ip_packet(tun2socks_ctx_t *ctx, uint8_t *packet, size_t len); +int process_tcp_packet(tun2socks_ctx_t *ctx, ip_header_t *ip, uint8_t *payload, size_t payload_len); +int process_udp_packet(tun2socks_ctx_t *ctx, ip_header_t *ip, uint8_t *payload, size_t payload_len); + +tcp_connection_t *find_tcp_connection(tun2socks_ctx_t *ctx, uint32_t src, uint16_t sport, + uint32_t dst, uint16_t dport); +tcp_connection_t *create_tcp_connection(tun2socks_ctx_t *ctx, uint32_t src, uint16_t sport, + uint32_t dst, uint16_t dport); +void destroy_tcp_connection(tun2socks_ctx_t *ctx, tcp_connection_t *conn); + +int socks5_connect(socks5_config_t *config, const char *dest_host, int dest_port); +int socks5_udp_associate(socks5_config_t *config, struct sockaddr_in *relay_addr); + +int send_tcp_packet(tun2socks_ctx_t *ctx, tcp_connection_t *conn, uint8_t flags, + uint8_t *data, size_t data_len); +int send_tcp_rst(tun2socks_ctx_t *ctx, ip_header_t *ip, tcp_header_t *tcp); + +uint16_t ip_checksum(void *data, size_t len); +uint16_t tcp_checksum(ip_header_t *ip, tcp_header_t *tcp, uint8_t *data, size_t data_len); +uint16_t udp_checksum(ip_header_t *ip, udp_header_t *udp, uint8_t *data, size_t data_len); + +#ifdef __cplusplus +} +#endif + +#endif // TUN2SOCKS_H diff --git a/app/src/main/cpp/tun2socks/tun2socks_jni.cpp b/app/src/main/cpp/tun2socks/tun2socks_jni.cpp new file mode 100644 index 00000000..3de65308 --- /dev/null +++ b/app/src/main/cpp/tun2socks/tun2socks_jni.cpp @@ -0,0 +1,88 @@ +/* + * JNI wrapper for tun2socks functionality + */ + +#include +#include +#include +#include + +extern "C" { +#include "core/tun2socks.h" +} + +#define LOG_TAG "tun2socks-jni" +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +// Global context +static tun2socks_ctx_t *g_ctx = nullptr; + +extern "C" { + +JNIEXPORT jint JNICALL +Java_org_proxydroid_utils_Tun2SocksHelper_startTun2Socks( + JNIEnv *env, + jclass clazz, + jint tunFd, + jint mtu, + jstring tunAddr, + jstring tunGateway, + jstring proxyUrl, + jstring dnsAddr) { + + if (g_ctx != nullptr) { + LOGW("tun2socks already running"); + return -1; + } + + const char *tunAddrStr = env->GetStringUTFChars(tunAddr, nullptr); + const char *tunGatewayStr = env->GetStringUTFChars(tunGateway, nullptr); + const char *proxyUrlStr = env->GetStringUTFChars(proxyUrl, nullptr); + const char *dnsAddrStr = env->GetStringUTFChars(dnsAddr, nullptr); + + LOGI("Starting tun2socks"); + LOGI(" TUN FD: %d", tunFd); + LOGI(" MTU: %d", mtu); + LOGI(" TUN Address: %s", tunAddrStr); + LOGI(" TUN Gateway: %s", tunGatewayStr); + LOGI(" Proxy URL: %s", proxyUrlStr); + LOGI(" DNS: %s", dnsAddrStr); + + // Create context + g_ctx = tun2socks_create(tunFd, mtu, tunAddrStr, proxyUrlStr, dnsAddrStr); + + env->ReleaseStringUTFChars(tunAddr, tunAddrStr); + env->ReleaseStringUTFChars(tunGateway, tunGatewayStr); + env->ReleaseStringUTFChars(proxyUrl, proxyUrlStr); + env->ReleaseStringUTFChars(dnsAddr, dnsAddrStr); + + if (g_ctx == nullptr) { + LOGE("Failed to create tun2socks context"); + return -1; + } + + // Run (blocking) + int result = tun2socks_run(g_ctx); + + // Cleanup + tun2socks_destroy(g_ctx); + g_ctx = nullptr; + + LOGI("tun2socks exited with code: %d", result); + return result; +} + +JNIEXPORT void JNICALL +Java_org_proxydroid_utils_Tun2SocksHelper_stopTun2Socks( + JNIEnv *env, + jclass clazz) { + LOGI("Stopping tun2socks"); + if (g_ctx != nullptr) { + tun2socks_stop(g_ctx); + } +} + +} // extern "C" diff --git a/app/src/main/java/org/proxydroid/AppManager.java b/app/src/main/java/org/proxydroid/AppManager.java index 6c58d7db..5ac664e6 100644 --- a/app/src/main/java/org/proxydroid/AppManager.java +++ b/app/src/main/java/org/proxydroid/AppManager.java @@ -121,17 +121,12 @@ public void onScroll(AbsListView view, @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - // app icon in action bar clicked; go home -// Intent intent = new Intent(this, ProxyDroid.class); -// intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); -// startActivity(intent); + int id = item.getItemId(); + if (id == android.R.id.home) { finish(); return true; - default: - return super.onOptionsItemSelected(item); } + return super.onOptionsItemSelected(item); } @Override @@ -319,6 +314,7 @@ public static ProxyedApp[] getProxyedApps(Context context, boolean self) { ProxyedApp app = new ProxyedApp(); app.setUid(aInfo.uid); + app.setPackageName(aInfo.packageName); app.setUsername(pMgr.getNameForUid(app.getUid())); @@ -390,6 +386,7 @@ public void getApps(Context context) { tApp.setEnabled(aInfo.enabled); tApp.setUid(aInfo.uid); + tApp.setPackageName(aInfo.packageName); tApp.setUsername(pMgr.getNameForUid(tApp.getUid())); tApp.setProcname(aInfo.processName); tApp.setName(pMgr.getApplicationLabel(aInfo).toString()); diff --git a/app/src/main/java/org/proxydroid/BypassListActivity.java b/app/src/main/java/org/proxydroid/BypassListActivity.java index 0321de78..f52e29d9 100644 --- a/app/src/main/java/org/proxydroid/BypassListActivity.java +++ b/app/src/main/java/org/proxydroid/BypassListActivity.java @@ -138,35 +138,26 @@ public void handleMessage(Message msg) { @Override public void onClick(View arg0) { - switch (arg0.getId()) { - case R.id.addBypassAddr: + int id = arg0.getId(); + if (id == R.id.addBypassAddr) { editAddr(MSG_ADD_ADDR, -1); - break; - case R.id.presetBypassAddr: + } else if (id == R.id.presetBypassAddr) { presetAddr(); - break; - case R.id.importBypassAddr: + } else if (id == R.id.importBypassAddr) { importAddr(); - break; - case R.id.exportBypassAddr: + } else if (id == R.id.exportBypassAddr) { exportAddr(); - break; } } @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - // app icon in action bar clicked; go home -// Intent intent = new Intent(this, ProxyDroid.class); -// intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); -// startActivity(intent); + int id = item.getItemId(); + if (id == android.R.id.home) { finish(); return true; - default: - return super.onOptionsItemSelected(item); } + return super.onOptionsItemSelected(item); } @Override diff --git a/app/src/main/java/org/proxydroid/ProxyDroid.java b/app/src/main/java/org/proxydroid/ProxyDroid.java index b957b22b..a8d2c972 100644 --- a/app/src/main/java/org/proxydroid/ProxyDroid.java +++ b/app/src/main/java/org/proxydroid/ProxyDroid.java @@ -38,6 +38,7 @@ package org.proxydroid; +import android.app.Activity; import android.app.AlertDialog; import android.app.ProgressDialog; import android.content.BroadcastReceiver; @@ -52,6 +53,7 @@ import android.content.res.AssetManager; import android.net.ConnectivityManager; import android.net.Uri; +import android.net.VpnService; import android.net.wifi.WifiConfiguration; import android.net.wifi.WifiManager; import android.os.Build; @@ -101,6 +103,7 @@ public class ProxyDroid extends PreferenceActivity private static final String TAG = "ProxyDroid"; private static final int MSG_UPDATE_FINISHED = 0; private static final int MSG_NO_ROOT = 1; + private static final int VPN_REQUEST_CODE = 100; final Handler handler = new Handler() { @Override public void handleMessage(Message msg) { @@ -121,6 +124,7 @@ public void handleMessage(Message msg) { private Profile mProfile = new Profile(); private CheckBoxPreference isAutoConnectCheck; private CheckBoxPreference isAutoSetProxyCheck; + private CheckBoxPreference isVpnModeCheck; private CheckBoxPreference isAuthCheck; private CheckBoxPreference isNTLMCheck; private CheckBoxPreference isPACCheck; @@ -352,6 +356,7 @@ public void onCreate(Bundle savedInstanceState) { isRunningCheck = (Preference) findPreference("isRunning"); isAutoSetProxyCheck = (CheckBoxPreference) findPreference("isAutoSetProxy"); + isVpnModeCheck = (CheckBoxPreference) findPreference("isVpnMode"); isAuthCheck = (CheckBoxPreference) findPreference("isAuth"); isNTLMCheck = (CheckBoxPreference) findPreference("isNTLM"); isPACCheck = (CheckBoxPreference) findPreference("isPAC"); @@ -392,7 +397,9 @@ public void run() { // Nothing } - if (!Utils.isRoot()) { + // Only check for root if VPN mode is disabled + boolean isVpnMode = settings.getBoolean("isVpnMode", true); + if (!isVpnMode && !Utils.isRoot()) { handler.sendEmptyMessage(MSG_NO_ROOT); } @@ -441,8 +448,15 @@ private boolean serviceStop() { if (!Utils.isWorking()) return false; + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this); + boolean isVpnMode = settings.getBoolean("isVpnMode", true); + try { - stopService(new Intent(ProxyDroid.this, ProxyDroidService.class)); + if (isVpnMode) { + stopService(new Intent(ProxyDroid.this, ProxyDroidVpnService.class)); + } else { + stopService(new Intent(ProxyDroid.this, ProxyDroidService.class)); + } } catch (Exception e) { return false; } @@ -457,11 +471,64 @@ private boolean serviceStart() { if (Utils.isWorking()) return false; SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this); + boolean isVpnMode = settings.getBoolean("isVpnMode", true); + + mProfile.getProfile(settings); + + if (isVpnMode) { + // Request VPN permission + Intent vpnIntent = VpnService.prepare(this); + if (vpnIntent != null) { + startActivityForResult(vpnIntent, VPN_REQUEST_CODE); + return true; // Will continue in onActivityResult + } else { + // Permission already granted, start VPN service + return startVpnService(); + } + } else { + // Use root-based service + return startRootService(); + } + } + private boolean startVpnService() { + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this); mProfile.getProfile(settings); try { + Intent it = new Intent(ProxyDroid.this, ProxyDroidVpnService.class); + Bundle bundle = new Bundle(); + bundle.putString("host", mProfile.getHost()); + bundle.putString("user", mProfile.getUser()); + bundle.putString("bypassAddrs", mProfile.getBypassAddrs()); + bundle.putString("password", mProfile.getPassword()); + bundle.putString("domain", mProfile.getDomain()); + bundle.putString("certificate", mProfile.getCertificate()); + + bundle.putString("proxyType", mProfile.getProxyType()); + bundle.putBoolean("isAutoSetProxy", mProfile.isAutoSetProxy()); + bundle.putBoolean("isBypassApps", mProfile.isBypassApps()); + bundle.putBoolean("isAuth", mProfile.isAuth()); + bundle.putBoolean("isNTLM", mProfile.isNTLM()); + bundle.putBoolean("isDNSProxy", mProfile.isDNSProxy()); + bundle.putBoolean("isPAC", mProfile.isPAC()); + + bundle.putInt("port", mProfile.getPort()); + it.putExtras(bundle); + startService(it); + } catch (Exception ignore) { + return false; + } + + return true; + } + + private boolean startRootService() { + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this); + mProfile.getProfile(settings); + + try { Intent it = new Intent(ProxyDroid.this, ProxyDroidService.class); Bundle bundle = new Bundle(); bundle.putString("host", mProfile.getHost()); @@ -484,13 +551,31 @@ private boolean serviceStart() { it.putExtras(bundle); startService(it); } catch (Exception ignore) { - // Nothing return false; } return true; } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == VPN_REQUEST_CODE) { + if (resultCode == Activity.RESULT_OK) { + // VPN permission granted, start the VPN service + startVpnService(); + } else { + // VPN permission denied + Toast.makeText(this, "VPN permission denied", Toast.LENGTH_SHORT).show(); + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this); + Editor ed = settings.edit(); + ed.putBoolean("isRunning", false); + ed.apply(); + } + } + } + private void onProfileChange(String oldProfileName) { SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this); @@ -568,6 +653,9 @@ private void disableAll() { isAutoConnectCheck.setEnabled(false); isPACCheck.setEnabled(false); isBypassAppsCheck.setEnabled(false); + if (isVpnModeCheck != null) { + isVpnModeCheck.setEnabled(false); + } } private void enableAll() { @@ -603,6 +691,9 @@ private void enableAll() { isAuthCheck.setEnabled(true); isAutoConnectCheck.setEnabled(true); isPACCheck.setEnabled(true); + if (isVpnModeCheck != null) { + isVpnModeCheck.setEnabled(true); + } } @Override @@ -883,6 +974,14 @@ public void onSharedPreferenceChanged(SharedPreferences settings, String key) { } } + if (key.equals("isVpnMode")) { + // When switching modes, update the root requirement message + boolean isVpnMode = settings.getBoolean("isVpnMode", true); + if (!isVpnMode && !Utils.isRoot()) { + showAToast(getString(R.string.require_root_alert)); + } + } + if (key.equals("isRunning")) { if (settings.getBoolean("isRunning", false)) { disableAll(); @@ -1121,28 +1220,37 @@ private void delProfile(String profile) { } private void reset() { + // Stop both services try { stopService(new Intent(ProxyDroid.this, ProxyDroidService.class)); } catch (Exception e) { // Nothing } + try { + stopService(new Intent(ProxyDroid.this, ProxyDroidVpnService.class)); + } catch (Exception e) { + // Nothing + } CopyAssets(); String filePath = getFilesDir().getAbsolutePath(); - Utils.runRootCommand(Utils.getIptables() - + " -t nat -F OUTPUT\n" - + getFilesDir().getAbsolutePath() - + "/proxy.sh stop\n" - + "kill -9 `cat " + filePath + "cntlm.pid`\n"); - - Utils.runRootCommand( - "chmod 700 " + filePath + "/redsocks\n" - + "chmod 700 " + filePath + "/proxy.sh\n" - + "chmod 700 " + filePath + "/gost.sh\n" - + "chmod 700 " + filePath + "/cntlm\n" - + "chmod 700 " + filePath + "/gost\n"); + // Only run root commands if root is available + if (Utils.isRoot()) { + Utils.runRootCommand(Utils.getIptables() + + " -t nat -F OUTPUT\n" + + getFilesDir().getAbsolutePath() + + "/proxy.sh stop\n" + + "kill -9 `cat " + filePath + "cntlm.pid`\n"); + + Utils.runRootCommand( + "chmod 700 " + filePath + "/redsocks\n" + + "chmod 700 " + filePath + "/proxy.sh\n" + + "chmod 700 " + filePath + "/gost.sh\n" + + "chmod 700 " + filePath + "/cntlm\n" + + "chmod 700 " + filePath + "/gost\n"); + } } @Override diff --git a/app/src/main/java/org/proxydroid/ProxyDroidVpnService.java b/app/src/main/java/org/proxydroid/ProxyDroidVpnService.java new file mode 100644 index 00000000..13bf031d --- /dev/null +++ b/app/src/main/java/org/proxydroid/ProxyDroidVpnService.java @@ -0,0 +1,559 @@ +/* proxydroid - Global / Individual Proxy App for Android + * Copyright (C) 2011 Max Lv + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.proxydroid; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.content.pm.PackageManager; +import android.media.AudioManager; +import android.net.Uri; +import android.net.VpnService; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.ParcelFileDescriptor; +import android.preference.PreferenceManager; +import android.util.Log; +import android.widget.RemoteViews; +import android.widget.Toast; + +import androidx.core.app.NotificationCompat; + +import com.btr.proxy.selector.pac.PacProxySelector; +import com.btr.proxy.selector.pac.PacScriptSource; +import com.btr.proxy.selector.pac.Proxy; +import com.btr.proxy.selector.pac.UrlPacScriptSource; + +import org.proxydroid.utils.Tun2SocksHelper; +import org.proxydroid.utils.LocalProxyServer; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Enumeration; +import java.util.List; + +/** + * VPN-based proxy service that doesn't require root permissions. + * Uses tun2socks to redirect traffic through a SOCKS proxy. + */ +public class ProxyDroidVpnService extends VpnService { + + private static final String TAG = "ProxyDroidVpnService"; + + // Notification + private NotificationManager notificationManager; + private PendingIntent pendIntent; + + // Message constants + private static final int MSG_CONNECT_START = 0; + private static final int MSG_CONNECT_FINISH = 1; + private static final int MSG_CONNECT_SUCCESS = 2; + private static final int MSG_CONNECT_FAIL = 3; + private static final int MSG_CONNECT_PAC_ERROR = 4; + private static final int MSG_CONNECT_RESOLVE_ERROR = 5; + + // VPN Constants + private static final String VPN_ADDRESS = "10.0.0.2"; + private static final String VPN_ROUTE = "0.0.0.0"; + private static final int VPN_MTU = 1500; + private static final String VPN_DNS = "8.8.8.8"; + private static final String VPN_DNS_SECONDARY = "8.8.4.4"; + + // Proxy configuration + private String host; + private String hostName; + private int port; + private String bypassAddrs = ""; + private String user; + private String password; + private String domain; + private String proxyType = "socks5"; + private boolean isAuth = false; + private boolean isNTLM = false; + private boolean isPAC = false; + + // App filtering + private boolean isAutoSetProxy = false; + private boolean isBypassApps = false; + private ProxyedApp[] apps; + + public String basePath; + + private SharedPreferences settings = null; + + // VPN resources + private ParcelFileDescriptor vpnInterface = null; + private Tun2SocksHelper tun2SocksHelper = null; + private LocalProxyServer localProxyServer = null; + + // Service state tracking + private static WeakReference sRunningInstance = null; + + public static boolean isServiceStarted() { + if (sRunningInstance == null) { + return false; + } else if (sRunningInstance.get() == null) { + sRunningInstance = null; + return false; + } + return true; + } + + private void markServiceStarted() { + sRunningInstance = new WeakReference<>(this); + } + + private void markServiceStopped() { + sRunningInstance = null; + } + + @Override + public void onCreate() { + super.onCreate(); + + basePath = getFilesDir().getAbsolutePath() + "/"; + + settings = PreferenceManager.getDefaultSharedPreferences(this); + notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + createNotificationChannel(); + + Intent intent = new Intent(this, ProxyDroid.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + int flags = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags = PendingIntent.FLAG_IMMUTABLE; + } + pendIntent = PendingIntent.getActivity(this, 0, intent, flags); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null || intent.getExtras() == null) { + return START_NOT_STICKY; + } + + ((ProxyDroidApplication) getApplication()) + .firebaseAnalytics.logEvent("vpn_service_start", null); + + Log.d(TAG, "VPN Service Start"); + + Bundle bundle = intent.getExtras(); + host = bundle.getString("host"); + bypassAddrs = bundle.getString("bypassAddrs"); + proxyType = bundle.getString("proxyType"); + port = bundle.getInt("port"); + isAutoSetProxy = bundle.getBoolean("isAutoSetProxy"); + isBypassApps = bundle.getBoolean("isBypassApps"); + isAuth = bundle.getBoolean("isAuth"); + isNTLM = bundle.getBoolean("isNTLM"); + isPAC = bundle.getBoolean("isPAC"); + + if (isAuth) { + user = bundle.getString("user"); + password = bundle.getString("password"); + } else { + user = ""; + password = ""; + } + + if (isNTLM) { + domain = bundle.getString("domain"); + } else { + domain = ""; + } + + new Thread(new Runnable() { + @Override + public void run() { + handler.sendEmptyMessage(MSG_CONNECT_START); + + if (getAddress() && startVpn()) { + notifyAlert(getString(R.string.forward_success) + " | " + getProfileName(), + getString(R.string.service_running)); + + handler.sendEmptyMessage(MSG_CONNECT_SUCCESS); + + // Update widget + try { + RemoteViews views = new RemoteViews(getPackageName(), + R.layout.proxydroid_appwidget); + views.setImageViewResource(R.id.serviceToggle, R.drawable.on); + AppWidgetManager awm = AppWidgetManager.getInstance(ProxyDroidVpnService.this); + awm.updateAppWidget(awm.getAppWidgetIds(new ComponentName( + ProxyDroidVpnService.this, ProxyDroidWidgetProvider.class)), views); + } catch (Exception ignore) { + } + } else { + stopSelf(); + handler.sendEmptyMessage(MSG_CONNECT_FAIL); + } + + handler.sendEmptyMessage(MSG_CONNECT_FINISH); + } + }).start(); + + markServiceStarted(); + + return START_STICKY; + } + + @Override + public void onDestroy() { + ((ProxyDroidApplication) getApplication()) + .firebaseAnalytics.logEvent("vpn_service_stop", null); + + notificationManager.cancelAll(); + stopForeground(true); + + stopVpn(); + + // Update widget + try { + RemoteViews views = new RemoteViews(getPackageName(), R.layout.proxydroid_appwidget); + views.setImageViewResource(R.id.serviceToggle, R.drawable.off); + AppWidgetManager awm = AppWidgetManager.getInstance(this); + awm.updateAppWidget( + awm.getAppWidgetIds(new ComponentName(this, ProxyDroidWidgetProvider.class)), + views); + } catch (Exception ignore) { + } + + Editor ed = settings.edit(); + ed.putBoolean("isRunning", false); + ed.apply(); + + try { + notificationManager.cancel(0); + } catch (Exception ignore) { + } + + markServiceStopped(); + + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onRevoke() { + stopVpn(); + stopSelf(); + super.onRevoke(); + } + + /** + * Start the VPN and tun2socks + */ + private boolean startVpn() { + try { + // Start local proxy server for HTTP proxy if needed + int socksPort = port; + String socksHost = host; + + if ("http".equals(proxyType) || "https".equals(proxyType) || "http-tunnel".equals(proxyType)) { + // Start local SOCKS server that forwards to HTTP proxy + localProxyServer = new LocalProxyServer(this, host, port, proxyType, + isAuth ? user : null, isAuth ? password : null); + if (!localProxyServer.start()) { + Log.e(TAG, "Failed to start local proxy server"); + return false; + } + socksHost = "127.0.0.1"; + socksPort = localProxyServer.getPort(); + Log.d(TAG, "Local SOCKS server started on port " + socksPort); + } + + // Build VPN interface + Builder builder = new Builder(); + builder.setSession(getString(R.string.app_name)); + builder.setMtu(VPN_MTU); + builder.addAddress(VPN_ADDRESS, 24); + builder.addRoute(VPN_ROUTE, 0); + builder.addDnsServer(VPN_DNS); + builder.addDnsServer(VPN_DNS_SECONDARY); + + // Handle per-app proxy + if (!isAutoSetProxy) { + if (apps == null || apps.length <= 0) { + apps = AppManager.getProxyedApps(this, !isBypassApps); + } + + for (ProxyedApp app : apps) { + if (app != null && app.isProxyed()) { + try { + if (isBypassApps) { + // Bypass these apps + builder.addDisallowedApplication(app.getPackageName()); + } else { + // Only proxy these apps + builder.addAllowedApplication(app.getPackageName()); + } + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, "App not found: " + app.getPackageName()); + } + } + } + } + + // Add bypass addresses + if (bypassAddrs != null && !bypassAddrs.isEmpty()) { + String[] addrs = Profile.decodeAddrs(bypassAddrs); + for (String addr : addrs) { + try { + // Add as excluded routes + if (addr.contains("/")) { + String[] parts = addr.split("/"); + // Skip this route from VPN + } + } catch (Exception e) { + Log.w(TAG, "Invalid bypass address: " + addr); + } + } + } + + // Exclude proxy server from VPN + try { + builder.addDisallowedApplication(getPackageName()); + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, "Could not exclude self from VPN"); + } + + // Establish VPN + vpnInterface = builder.establish(); + if (vpnInterface == null) { + Log.e(TAG, "Failed to establish VPN interface"); + return false; + } + + Log.d(TAG, "VPN interface established with fd: " + vpnInterface.getFd()); + + // Prepare tun2socks SOCKS proxy URL + String proxyUrl; + if (isAuth && user != null && !user.isEmpty()) { + proxyUrl = String.format("socks5://%s:%s@%s:%d", user, password, socksHost, socksPort); + } else { + proxyUrl = String.format("socks5://%s:%d", socksHost, socksPort); + } + + // Start tun2socks + tun2SocksHelper = new Tun2SocksHelper(this, vpnInterface.getFd(), VPN_MTU, + VPN_ADDRESS, proxyUrl, VPN_DNS); + + if (!tun2SocksHelper.start()) { + Log.e(TAG, "Failed to start tun2socks"); + vpnInterface.close(); + vpnInterface = null; + return false; + } + + Log.d(TAG, "tun2socks started successfully"); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error starting VPN", e); + return false; + } + } + + /** + * Stop VPN and clean up resources + */ + private void stopVpn() { + // Stop tun2socks + if (tun2SocksHelper != null) { + tun2SocksHelper.stop(); + tun2SocksHelper = null; + } + + // Stop local proxy server + if (localProxyServer != null) { + localProxyServer.stop(); + localProxyServer = null; + } + + // Close VPN interface + if (vpnInterface != null) { + try { + vpnInterface.close(); + } catch (IOException e) { + Log.e(TAG, "Error closing VPN interface", e); + } + vpnInterface = null; + } + } + + private boolean getAddress() { + if (isPAC) { + try { + PacScriptSource src = new UrlPacScriptSource(host); + PacProxySelector ps = new PacProxySelector(src); + URI uri = new URI("http://gaednsproxy.appspot.com"); + List list = ps.select(uri); + if (list != null && list.size() != 0) { + Proxy p = list.get(0); + if (p.equals(Proxy.NO_PROXY) || p.host == null || p.port == 0 || p.type == null) { + handler.sendEmptyMessageDelayed(MSG_CONNECT_PAC_ERROR, 3000); + return false; + } + proxyType = p.type; + host = p.host; + port = p.port; + } else { + handler.sendEmptyMessageDelayed(MSG_CONNECT_PAC_ERROR, 3000); + return false; + } + } catch (URISyntaxException e) { + handler.sendEmptyMessageDelayed(MSG_CONNECT_PAC_ERROR, 3000); + return false; + } + } + + hostName = host; + + try { + host = InetAddress.getByName(host).getHostAddress(); + } catch (UnknownHostException e) { + host = hostName; + handler.sendEmptyMessageDelayed(MSG_CONNECT_RESOLVE_ERROR, 3000); + return false; + } + + Log.d(TAG, "Proxy: " + host); + Log.d(TAG, "Port: " + port); + + return true; + } + + private String getProfileName() { + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this); + return settings.getString("profile" + settings.getString("profile", "1"), + getString(R.string.profile_base) + " " + settings.getString("profile", "1")); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = "ProxyDroid VPN Service"; + String description = "ProxyDroid VPN Background Service"; + int importance = NotificationManager.IMPORTANCE_LOW; + NotificationChannel channel = new NotificationChannel("VpnService", name, importance); + channel.setDescription(description); + notificationManager.createNotificationChannel(channel); + } + } + + private void initSoundVibrateLights(NotificationCompat.Builder builder) { + final String ringtone = settings.getString("settings_key_notif_ringtone", null); + AudioManager audioManager = (AudioManager) this.getSystemService(Context.AUDIO_SERVICE); + if (audioManager.getStreamVolume(AudioManager.STREAM_RING) == 0) { + builder.setSound(null); + } else if (ringtone != null) { + builder.setSound(Uri.parse(ringtone)); + } + + if (settings.getBoolean("settings_key_notif_vibrate", false)) { + builder.setVibrate(new long[]{0, 1000, 500, 1000, 500, 1000}); + } + } + + private void notifyAlert(String title, String info) { + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "VpnService"); + + initSoundVibrateLights(builder); + + builder.setAutoCancel(false); + builder.setTicker(title); + builder.setContentTitle(getString(R.string.app_name) + " | " + getProfileName()); + builder.setContentText(info); + builder.setSmallIcon(R.drawable.ic_stat_proxydroid); + builder.setContentIntent(pendIntent); + builder.setPriority(NotificationCompat.PRIORITY_LOW); + builder.setOngoing(true); + + startForeground(1, builder.build()); + } + + final Handler handler = new Handler() { + @Override + public void handleMessage(Message msg) { + Editor ed = settings.edit(); + switch (msg.what) { + case MSG_CONNECT_START: + ed.putBoolean("isConnecting", true); + break; + case MSG_CONNECT_FINISH: + ed.putBoolean("isConnecting", false); + break; + case MSG_CONNECT_SUCCESS: + ed.putBoolean("isRunning", true); + break; + case MSG_CONNECT_FAIL: + ed.putBoolean("isRunning", false); + break; + case MSG_CONNECT_PAC_ERROR: + Toast.makeText(ProxyDroidVpnService.this, R.string.msg_pac_error, + Toast.LENGTH_SHORT).show(); + break; + case MSG_CONNECT_RESOLVE_ERROR: + Toast.makeText(ProxyDroidVpnService.this, R.string.msg_resolve_error, + Toast.LENGTH_SHORT).show(); + break; + } + ed.apply(); + super.handleMessage(msg); + } + }; + + public String getLocalIpAddress() { + try { + for (Enumeration en = NetworkInterface.getNetworkInterfaces(); + en.hasMoreElements(); ) { + NetworkInterface intf = en.nextElement(); + for (Enumeration enumIpAddr = intf.getInetAddresses(); + enumIpAddr.hasMoreElements(); ) { + InetAddress inetAddress = enumIpAddr.nextElement(); + if (!inetAddress.isLoopbackAddress()) { + return inetAddress.getHostAddress(); + } + } + } + } catch (SocketException ex) { + Log.e(TAG, ex.toString()); + } + return null; + } +} diff --git a/app/src/main/java/org/proxydroid/ProxyedApp.java b/app/src/main/java/org/proxydroid/ProxyedApp.java index 6568afeb..4e9d9a89 100644 --- a/app/src/main/java/org/proxydroid/ProxyedApp.java +++ b/app/src/main/java/org/proxydroid/ProxyedApp.java @@ -8,6 +8,7 @@ public class ProxyedApp { private String username; private String procname; private String name; + private String packageName; private boolean proxyed = false; @@ -101,4 +102,19 @@ public void setName(String name) { this.name = name; } + /** + * @return the packageName + */ + public String getPackageName() { + return packageName; + } + + /** + * @param packageName + * the packageName to set + */ + public void setPackageName(String packageName) { + this.packageName = packageName; + } + } \ No newline at end of file diff --git a/app/src/main/java/org/proxydroid/utils/LocalProxyServer.java b/app/src/main/java/org/proxydroid/utils/LocalProxyServer.java new file mode 100644 index 00000000..1a567b27 --- /dev/null +++ b/app/src/main/java/org/proxydroid/utils/LocalProxyServer.java @@ -0,0 +1,395 @@ +/* proxydroid - Global / Individual Proxy App for Android + * Copyright (C) 2011 Max Lv + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.proxydroid.utils; + +import android.content.Context; +import android.util.Base64; +import android.util.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Local SOCKS5 server that forwards connections through an HTTP proxy. + * This allows tun2socks to work with HTTP proxies by providing a local SOCKS interface. + */ +public class LocalProxyServer { + + private static final String TAG = "LocalProxyServer"; + + private static final int DEFAULT_PORT = 10800; + private static final int SOCKS5_VERSION = 0x05; + private static final int SOCKS5_CMD_CONNECT = 0x01; + private static final int SOCKS5_ATYP_IPV4 = 0x01; + private static final int SOCKS5_ATYP_DOMAIN = 0x03; + private static final int SOCKS5_ATYP_IPV6 = 0x04; + + private Context context; + private String remoteHost; + private int remotePort; + private String proxyType; + private String username; + private String password; + + private ServerSocket serverSocket; + private ExecutorService executor; + private volatile boolean running = false; + private Thread serverThread; + private int localPort; + + public LocalProxyServer(Context context, String remoteHost, int remotePort, + String proxyType, String username, String password) { + this.context = context; + this.remoteHost = remoteHost; + this.remotePort = remotePort; + this.proxyType = proxyType; + this.username = username; + this.password = password; + } + + /** + * Start the local SOCKS5 server + */ + public boolean start() { + if (running) { + return true; + } + + try { + serverSocket = new ServerSocket(0); // Bind to any available port + localPort = serverSocket.getLocalPort(); + executor = Executors.newCachedThreadPool(); + running = true; + + serverThread = new Thread(new Runnable() { + @Override + public void run() { + Log.d(TAG, "Local SOCKS5 server started on port " + localPort); + while (running && !Thread.currentThread().isInterrupted()) { + try { + Socket clientSocket = serverSocket.accept(); + executor.submit(new ClientHandler(clientSocket)); + } catch (SocketException e) { + if (running) { + Log.e(TAG, "Socket accept error", e); + } + } catch (IOException e) { + if (running) { + Log.e(TAG, "IO error accepting connection", e); + } + } + } + Log.d(TAG, "Local SOCKS5 server stopped"); + } + }, "local-socks-server"); + + serverThread.start(); + return true; + + } catch (IOException e) { + Log.e(TAG, "Failed to start local server", e); + return false; + } + } + + /** + * Stop the local server + */ + public void stop() { + running = false; + + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException e) { + Log.e(TAG, "Error closing server socket", e); + } + serverSocket = null; + } + + if (executor != null) { + executor.shutdownNow(); + try { + executor.awaitTermination(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + executor = null; + } + + if (serverThread != null) { + serverThread.interrupt(); + try { + serverThread.join(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + serverThread = null; + } + } + + /** + * Get the local port the server is listening on + */ + public int getPort() { + return localPort; + } + + /** + * Check if the server is running + */ + public boolean isRunning() { + return running; + } + + /** + * Handler for individual client connections + */ + private class ClientHandler implements Runnable { + private Socket clientSocket; + + public ClientHandler(Socket clientSocket) { + this.clientSocket = clientSocket; + } + + @Override + public void run() { + try { + handleSocks5Connection(); + } catch (IOException e) { + Log.d(TAG, "Connection closed: " + e.getMessage()); + } finally { + try { + clientSocket.close(); + } catch (IOException e) { + // ignore + } + } + } + + private void handleSocks5Connection() throws IOException { + InputStream in = clientSocket.getInputStream(); + OutputStream out = clientSocket.getOutputStream(); + + // SOCKS5 handshake + int version = in.read(); + if (version != SOCKS5_VERSION) { + Log.w(TAG, "Unsupported SOCKS version: " + version); + return; + } + + // Read authentication methods + int numMethods = in.read(); + byte[] methods = new byte[numMethods]; + in.read(methods); + + // Reply with no authentication required + out.write(new byte[]{SOCKS5_VERSION, 0x00}); + out.flush(); + + // Read connect request + version = in.read(); + if (version != SOCKS5_VERSION) { + return; + } + + int cmd = in.read(); + if (cmd != SOCKS5_CMD_CONNECT) { + // Send command not supported error + out.write(new byte[]{SOCKS5_VERSION, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); + out.flush(); + return; + } + + in.read(); // Reserved + + // Read address type and destination + int addrType = in.read(); + String destHost; + int destPort; + + switch (addrType) { + case SOCKS5_ATYP_IPV4: + byte[] ipv4 = new byte[4]; + in.read(ipv4); + destHost = String.format("%d.%d.%d.%d", + ipv4[0] & 0xFF, ipv4[1] & 0xFF, ipv4[2] & 0xFF, ipv4[3] & 0xFF); + break; + case SOCKS5_ATYP_DOMAIN: + int domainLen = in.read(); + byte[] domain = new byte[domainLen]; + in.read(domain); + destHost = new String(domain, StandardCharsets.UTF_8); + break; + case SOCKS5_ATYP_IPV6: + byte[] ipv6 = new byte[16]; + in.read(ipv6); + // Convert to string representation + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16; i += 2) { + if (i > 0) sb.append(':'); + sb.append(String.format("%02x%02x", ipv6[i] & 0xFF, ipv6[i + 1] & 0xFF)); + } + destHost = sb.toString(); + break; + default: + // Send address type not supported error + out.write(new byte[]{SOCKS5_VERSION, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); + out.flush(); + return; + } + + // Read destination port + destPort = (in.read() << 8) | in.read(); + + Log.d(TAG, "Connect request to " + destHost + ":" + destPort); + + // Connect through HTTP proxy + Socket proxySocket = null; + try { + proxySocket = connectThroughHttpProxy(destHost, destPort); + + // Send success response + out.write(new byte[]{ + SOCKS5_VERSION, 0x00, 0x00, 0x01, + 0, 0, 0, 0, // Bound address (0.0.0.0) + 0, 0 // Bound port (0) + }); + out.flush(); + + // Relay data between client and proxy + relayData(clientSocket, proxySocket); + + } catch (IOException e) { + Log.e(TAG, "Failed to connect through proxy: " + e.getMessage()); + // Send connection refused error + out.write(new byte[]{SOCKS5_VERSION, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); + out.flush(); + } finally { + if (proxySocket != null) { + try { + proxySocket.close(); + } catch (IOException e) { + // ignore + } + } + } + } + + private Socket connectThroughHttpProxy(String destHost, int destPort) throws IOException { + Socket proxySocket = new Socket(); + proxySocket.connect(new InetSocketAddress(remoteHost, remotePort), 10000); + + InputStream proxyIn = proxySocket.getInputStream(); + OutputStream proxyOut = proxySocket.getOutputStream(); + + // Send HTTP CONNECT request + StringBuilder request = new StringBuilder(); + request.append("CONNECT ").append(destHost).append(":").append(destPort) + .append(" HTTP/1.1\r\n"); + request.append("Host: ").append(destHost).append(":").append(destPort).append("\r\n"); + + // Add proxy authentication if needed + if (username != null && !username.isEmpty()) { + String auth = username + ":" + (password != null ? password : ""); + String encodedAuth = Base64.encodeToString(auth.getBytes(StandardCharsets.UTF_8), + Base64.NO_WRAP); + request.append("Proxy-Authorization: Basic ").append(encodedAuth).append("\r\n"); + } + + request.append("Proxy-Connection: keep-alive\r\n"); + request.append("\r\n"); + + proxyOut.write(request.toString().getBytes(StandardCharsets.UTF_8)); + proxyOut.flush(); + + // Read response + StringBuilder response = new StringBuilder(); + int ch; + while ((ch = proxyIn.read()) != -1) { + response.append((char) ch); + if (response.toString().endsWith("\r\n\r\n")) { + break; + } + } + + String responseStr = response.toString(); + Log.d(TAG, "Proxy response: " + responseStr.split("\r\n")[0]); + + // Check for success (HTTP/1.x 200) + if (!responseStr.contains(" 200 ")) { + proxySocket.close(); + throw new IOException("Proxy connection failed: " + responseStr.split("\r\n")[0]); + } + + return proxySocket; + } + + private void relayData(final Socket client, final Socket proxy) { + Thread clientToProxy = new Thread(new Runnable() { + @Override + public void run() { + try { + copyStream(client.getInputStream(), proxy.getOutputStream()); + } catch (IOException e) { + // Connection closed + } + } + }); + + Thread proxyToClient = new Thread(new Runnable() { + @Override + public void run() { + try { + copyStream(proxy.getInputStream(), client.getOutputStream()); + } catch (IOException e) { + // Connection closed + } + } + }); + + clientToProxy.start(); + proxyToClient.start(); + + try { + clientToProxy.join(); + proxyToClient.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void copyStream(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + out.flush(); + } + } + } +} diff --git a/app/src/main/java/org/proxydroid/utils/Tun2SocksHelper.java b/app/src/main/java/org/proxydroid/utils/Tun2SocksHelper.java new file mode 100644 index 00000000..16119fd2 --- /dev/null +++ b/app/src/main/java/org/proxydroid/utils/Tun2SocksHelper.java @@ -0,0 +1,172 @@ +/* proxydroid - Global / Individual Proxy App for Android + * Copyright (C) 2011 Max Lv + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.proxydroid.utils; + +import android.content.Context; +import android.util.Log; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Helper class to manage tun2socks process. + * tun2socks converts TUN device traffic to SOCKS proxy protocol. + */ +public class Tun2SocksHelper { + + private static final String TAG = "Tun2SocksHelper"; + + private Context context; + private int tunFd; + private int mtu; + private String tunAddress; + private String proxyUrl; + private String dnsServer; + + private Thread tun2SocksThread; + private volatile boolean running = false; + + // Native library loaded flag + private static boolean libraryLoaded = false; + + static { + try { + System.loadLibrary("tun2socks"); + libraryLoaded = true; + Log.d(TAG, "tun2socks library loaded successfully"); + } catch (UnsatisfiedLinkError e) { + Log.e(TAG, "Failed to load tun2socks library", e); + libraryLoaded = false; + } + } + + // Native methods + private static native int startTun2Socks(int tunFd, int mtu, String tunAddr, + String tunGateway, String proxyUrl, String dnsAddr); + private static native void stopTun2Socks(); + + public Tun2SocksHelper(Context context, int tunFd, int mtu, String tunAddress, + String proxyUrl, String dnsServer) { + this.context = context; + this.tunFd = tunFd; + this.mtu = mtu; + this.tunAddress = tunAddress; + this.proxyUrl = proxyUrl; + this.dnsServer = dnsServer; + } + + /** + * Start tun2socks in a background thread + */ + public boolean start() { + if (!libraryLoaded) { + Log.e(TAG, "Cannot start tun2socks: library not loaded"); + return false; + } + + if (running) { + Log.w(TAG, "tun2socks already running"); + return true; + } + + running = true; + + tun2SocksThread = new Thread(new Runnable() { + @Override + public void run() { + Log.d(TAG, "Starting tun2socks thread"); + Log.d(TAG, "tunFd: " + tunFd); + Log.d(TAG, "mtu: " + mtu); + Log.d(TAG, "tunAddress: " + tunAddress); + Log.d(TAG, "proxyUrl: " + proxyUrl); + Log.d(TAG, "dnsServer: " + dnsServer); + + // Gateway is typically .1 of the subnet + String tunGateway = tunAddress.substring(0, tunAddress.lastIndexOf('.')) + ".1"; + + try { + int result = startTun2Socks(tunFd, mtu, tunAddress, tunGateway, + proxyUrl, dnsServer); + Log.d(TAG, "tun2socks exited with code: " + result); + } catch (Exception e) { + Log.e(TAG, "Error running tun2socks", e); + } + + running = false; + } + }, "tun2socks-thread"); + + tun2SocksThread.start(); + + // Give it a moment to start + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + return running; + } + + /** + * Stop tun2socks + */ + public void stop() { + if (!running) { + return; + } + + Log.d(TAG, "Stopping tun2socks"); + running = false; + + if (libraryLoaded) { + try { + stopTun2Socks(); + } catch (Exception e) { + Log.e(TAG, "Error stopping tun2socks", e); + } + } + + if (tun2SocksThread != null) { + try { + tun2SocksThread.interrupt(); + tun2SocksThread.join(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + tun2SocksThread = null; + } + } + + /** + * Check if tun2socks is running + */ + public boolean isRunning() { + return running && tun2SocksThread != null && tun2SocksThread.isAlive(); + } + + /** + * Check if the native library is available + */ + public static boolean isLibraryLoaded() { + return libraryLoaded; + } +} diff --git a/app/src/main/java/org/proxydroid/utils/Utils.java b/app/src/main/java/org/proxydroid/utils/Utils.java index cac1c9bd..5729412f 100644 --- a/app/src/main/java/org/proxydroid/utils/Utils.java +++ b/app/src/main/java/org/proxydroid/utils/Utils.java @@ -275,7 +275,8 @@ private synchronized static int runScript(String script, StringBuilder res, } public static boolean isWorking() { - return ProxyDroidService.isServiceStarted(); + return ProxyDroidService.isServiceStarted() || + org.proxydroid.ProxyDroidVpnService.isServiceStarted(); } public static void CopyStream(InputStream is, OutputStream os) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16100773..cf9ae308 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -189,4 +189,10 @@ Incorrect or unsupported PAC file Incorrect or unknown proxy host name + + VPN Mode (No Root) + Use VPN service instead of root. Works without root but may use more battery. + VPN Connection + ProxyDroid needs to create a VPN connection to route traffic through the proxy. Continue? + diff --git a/app/src/main/res/xml-v14/proxydroid_preference.xml b/app/src/main/res/xml-v14/proxydroid_preference.xml index 365b941d..eb7e1398 100644 --- a/app/src/main/res/xml-v14/proxydroid_preference.xml +++ b/app/src/main/res/xml-v14/proxydroid_preference.xml @@ -109,6 +109,13 @@ + + + + + + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.proxydroid.utils; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** + * Tests for local HTTP proxy functionality. + * Tests SOCKS5 protocol handling and HTTP CONNECT tunneling. + */ +public class LocalHttpProxyTest { + + private static final int SOCKS5_VERSION = 0x05; + private static final int SOCKS5_CMD_CONNECT = 0x01; + private static final int SOCKS5_ATYP_IPV4 = 0x01; + private static final int SOCKS5_ATYP_DOMAIN = 0x03; + + private MockHttpProxy mockHttpProxy; + private TestLocalSocksServer testSocksServer; + private ExecutorService executor; + + @Before + public void setUp() throws Exception { + executor = Executors.newCachedThreadPool(); + } + + @After + public void tearDown() throws Exception { + if (mockHttpProxy != null) { + mockHttpProxy.stop(); + } + if (testSocksServer != null) { + testSocksServer.stop(); + } + if (executor != null) { + executor.shutdownNow(); + executor.awaitTermination(2, TimeUnit.SECONDS); + } + } + + @Test + public void testSocks5Handshake() throws Exception { + // Create a mock HTTP proxy + mockHttpProxy = new MockHttpProxy(true); + mockHttpProxy.start(); + + // Create a local SOCKS5 server that forwards to HTTP proxy + testSocksServer = new TestLocalSocksServer( + "127.0.0.1", mockHttpProxy.getPort(), null, null); + testSocksServer.start(); + + // Connect as SOCKS5 client + Socket client = new Socket(); + client.connect(new InetSocketAddress("127.0.0.1", testSocksServer.getPort()), 5000); + client.setSoTimeout(5000); + + try { + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + + // Send SOCKS5 greeting + out.write(new byte[]{SOCKS5_VERSION, 0x01, 0x00}); // Version 5, 1 method, no auth + out.flush(); + + // Read response + byte[] response = new byte[2]; + int read = in.read(response); + assertEquals(2, read); + assertEquals(SOCKS5_VERSION, response[0]); + assertEquals(0x00, response[1]); // No auth required + + } finally { + client.close(); + } + } + + @Test + public void testSocks5ConnectIPv4() throws Exception { + // Create a mock HTTP proxy + mockHttpProxy = new MockHttpProxy(true); + mockHttpProxy.start(); + + // Create a local SOCKS5 server + testSocksServer = new TestLocalSocksServer( + "127.0.0.1", mockHttpProxy.getPort(), null, null); + testSocksServer.start(); + + // Connect as SOCKS5 client + Socket client = new Socket(); + client.connect(new InetSocketAddress("127.0.0.1", testSocksServer.getPort()), 5000); + client.setSoTimeout(5000); + + try { + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + + // SOCKS5 handshake + out.write(new byte[]{SOCKS5_VERSION, 0x01, 0x00}); + out.flush(); + byte[] handshake = new byte[2]; + in.read(handshake); + assertEquals(SOCKS5_VERSION, handshake[0]); + + // Send CONNECT request to 93.184.216.34:80 (example.com) + byte[] connectReq = new byte[]{ + SOCKS5_VERSION, // Version + SOCKS5_CMD_CONNECT, // Connect command + 0x00, // Reserved + SOCKS5_ATYP_IPV4, // IPv4 address type + 93, (byte) 184, (byte) 216, 34, // IP address + 0x00, 0x50 // Port 80 + }; + out.write(connectReq); + out.flush(); + + // Read response + byte[] response = new byte[10]; + int read = in.read(response); + assertEquals(10, read); + assertEquals(SOCKS5_VERSION, response[0]); + assertEquals(0x00, response[1]); // Success + + // Verify HTTP proxy received CONNECT request + assertTrue(mockHttpProxy.getLastRequest().contains("CONNECT 93.184.216.34:80")); + + } finally { + client.close(); + } + } + + @Test + public void testSocks5ConnectDomain() throws Exception { + // Create a mock HTTP proxy + mockHttpProxy = new MockHttpProxy(true); + mockHttpProxy.start(); + + // Create a local SOCKS5 server + testSocksServer = new TestLocalSocksServer( + "127.0.0.1", mockHttpProxy.getPort(), null, null); + testSocksServer.start(); + + // Connect as SOCKS5 client + Socket client = new Socket(); + client.connect(new InetSocketAddress("127.0.0.1", testSocksServer.getPort()), 5000); + client.setSoTimeout(5000); + + try { + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + + // SOCKS5 handshake + out.write(new byte[]{SOCKS5_VERSION, 0x01, 0x00}); + out.flush(); + byte[] handshake = new byte[2]; + in.read(handshake); + + // Send CONNECT request to example.com:443 + String domain = "example.com"; + byte[] domainBytes = domain.getBytes(StandardCharsets.UTF_8); + byte[] connectReq = new byte[4 + 1 + domainBytes.length + 2]; + connectReq[0] = SOCKS5_VERSION; + connectReq[1] = SOCKS5_CMD_CONNECT; + connectReq[2] = 0x00; + connectReq[3] = SOCKS5_ATYP_DOMAIN; + connectReq[4] = (byte) domainBytes.length; + System.arraycopy(domainBytes, 0, connectReq, 5, domainBytes.length); + connectReq[5 + domainBytes.length] = 0x01; // Port 443 high byte + connectReq[6 + domainBytes.length] = (byte) 0xBB; // Port 443 low byte + + out.write(connectReq); + out.flush(); + + // Read response + byte[] response = new byte[10]; + int read = in.read(response); + assertEquals(10, read); + assertEquals(SOCKS5_VERSION, response[0]); + assertEquals(0x00, response[1]); // Success + + // Verify HTTP proxy received CONNECT request + assertTrue(mockHttpProxy.getLastRequest().contains("CONNECT example.com:443")); + + } finally { + client.close(); + } + } + + @Test + public void testHttpProxyAuthentication() throws Exception { + // Create a mock HTTP proxy requiring auth + mockHttpProxy = new MockHttpProxy(true); + mockHttpProxy.setRequireAuth("testuser", "testpass"); + mockHttpProxy.start(); + + // Create a local SOCKS5 server with credentials + testSocksServer = new TestLocalSocksServer( + "127.0.0.1", mockHttpProxy.getPort(), "testuser", "testpass"); + testSocksServer.start(); + + // Connect as SOCKS5 client + Socket client = new Socket(); + client.connect(new InetSocketAddress("127.0.0.1", testSocksServer.getPort()), 5000); + client.setSoTimeout(5000); + + try { + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + + // SOCKS5 handshake + out.write(new byte[]{SOCKS5_VERSION, 0x01, 0x00}); + out.flush(); + byte[] handshake = new byte[2]; + in.read(handshake); + + // Send CONNECT request + String domain = "secure.example.com"; + byte[] domainBytes = domain.getBytes(StandardCharsets.UTF_8); + byte[] connectReq = new byte[4 + 1 + domainBytes.length + 2]; + connectReq[0] = SOCKS5_VERSION; + connectReq[1] = SOCKS5_CMD_CONNECT; + connectReq[2] = 0x00; + connectReq[3] = SOCKS5_ATYP_DOMAIN; + connectReq[4] = (byte) domainBytes.length; + System.arraycopy(domainBytes, 0, connectReq, 5, domainBytes.length); + connectReq[5 + domainBytes.length] = 0x01; + connectReq[6 + domainBytes.length] = (byte) 0xBB; + + out.write(connectReq); + out.flush(); + + // Read response + byte[] response = new byte[10]; + int read = in.read(response); + assertEquals(10, read); + assertEquals(SOCKS5_VERSION, response[0]); + assertEquals(0x00, response[1]); // Success + + // Verify HTTP proxy received auth header + String request = mockHttpProxy.getLastRequest(); + assertTrue(request.contains("Proxy-Authorization: Basic")); + String expectedAuth = Base64.getEncoder().encodeToString("testuser:testpass".getBytes(StandardCharsets.UTF_8)); + assertTrue(request.contains(expectedAuth)); + + } finally { + client.close(); + } + } + + @Test + public void testHttpProxyConnectionFailure() throws Exception { + // Create a mock HTTP proxy that rejects connections + mockHttpProxy = new MockHttpProxy(false); // Will return 403 + mockHttpProxy.start(); + + // Create a local SOCKS5 server + testSocksServer = new TestLocalSocksServer( + "127.0.0.1", mockHttpProxy.getPort(), null, null); + testSocksServer.start(); + + // Connect as SOCKS5 client + Socket client = new Socket(); + client.connect(new InetSocketAddress("127.0.0.1", testSocksServer.getPort()), 5000); + client.setSoTimeout(5000); + + try { + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + + // SOCKS5 handshake + out.write(new byte[]{SOCKS5_VERSION, 0x01, 0x00}); + out.flush(); + byte[] handshake = new byte[2]; + in.read(handshake); + + // Send CONNECT request + byte[] connectReq = new byte[]{ + SOCKS5_VERSION, + SOCKS5_CMD_CONNECT, + 0x00, + SOCKS5_ATYP_IPV4, + 10, 0, 0, 1, + 0x00, 0x50 + }; + out.write(connectReq); + out.flush(); + + // Read response - should indicate failure + byte[] response = new byte[10]; + int read = in.read(response); + assertEquals(10, read); + assertEquals(SOCKS5_VERSION, response[0]); + assertEquals(0x05, response[1]); // Connection refused + + } finally { + client.close(); + } + } + + @Test + public void testDataRelay() throws Exception { + // Create a mock HTTP proxy that echoes data + mockHttpProxy = new MockHttpProxy(true); + mockHttpProxy.setEchoMode(true); + mockHttpProxy.start(); + + // Create a local SOCKS5 server + testSocksServer = new TestLocalSocksServer( + "127.0.0.1", mockHttpProxy.getPort(), null, null); + testSocksServer.start(); + + // Connect as SOCKS5 client + Socket client = new Socket(); + client.connect(new InetSocketAddress("127.0.0.1", testSocksServer.getPort()), 5000); + client.setSoTimeout(5000); + + try { + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + + // SOCKS5 handshake + out.write(new byte[]{SOCKS5_VERSION, 0x01, 0x00}); + out.flush(); + byte[] handshake = new byte[2]; + in.read(handshake); + + // Send CONNECT request + byte[] connectReq = new byte[]{ + SOCKS5_VERSION, + SOCKS5_CMD_CONNECT, + 0x00, + SOCKS5_ATYP_IPV4, + 8, 8, 8, 8, + 0x00, 0x50 + }; + out.write(connectReq); + out.flush(); + + // Read connect response + byte[] response = new byte[10]; + in.read(response); + assertEquals(0x00, response[1]); // Success + + // Send test data + String testData = "Hello, Proxy!"; + out.write(testData.getBytes(StandardCharsets.UTF_8)); + out.flush(); + + // Read echoed data + byte[] buffer = new byte[1024]; + int read = in.read(buffer); + String echoed = new String(buffer, 0, read, StandardCharsets.UTF_8); + assertEquals(testData, echoed); + + } finally { + client.close(); + } + } + + @Test + public void testServerStartStop() throws Exception { + mockHttpProxy = new MockHttpProxy(true); + mockHttpProxy.start(); + + testSocksServer = new TestLocalSocksServer( + "127.0.0.1", mockHttpProxy.getPort(), null, null); + + assertFalse(testSocksServer.isRunning()); + + assertTrue(testSocksServer.start()); + assertTrue(testSocksServer.isRunning()); + assertTrue(testSocksServer.getPort() > 0); + + testSocksServer.stop(); + assertFalse(testSocksServer.isRunning()); + } + + @Test + public void testConcurrentConnections() throws Exception { + mockHttpProxy = new MockHttpProxy(true); + mockHttpProxy.start(); + + testSocksServer = new TestLocalSocksServer( + "127.0.0.1", mockHttpProxy.getPort(), null, null); + testSocksServer.start(); + + int numClients = 5; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(numClients); + AtomicBoolean allSucceeded = new AtomicBoolean(true); + + for (int i = 0; i < numClients; i++) { + final int clientId = i; + executor.submit(() -> { + try { + startLatch.await(); + Socket client = new Socket(); + client.connect(new InetSocketAddress("127.0.0.1", testSocksServer.getPort()), 5000); + client.setSoTimeout(5000); + + try { + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + + // SOCKS5 handshake + out.write(new byte[]{SOCKS5_VERSION, 0x01, 0x00}); + out.flush(); + byte[] handshake = new byte[2]; + in.read(handshake); + + if (handshake[0] != SOCKS5_VERSION || handshake[1] != 0x00) { + allSucceeded.set(false); + } + } finally { + client.close(); + } + } catch (Exception e) { + allSucceeded.set(false); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(10, TimeUnit.SECONDS)); + assertTrue(allSucceeded.get()); + } + + /** + * Mock HTTP proxy server for testing + */ + private static class MockHttpProxy { + private ServerSocket serverSocket; + private volatile boolean running = false; + private Thread serverThread; + private boolean acceptConnections; + private String requiredUsername; + private String requiredPassword; + private boolean echoMode = false; + private AtomicReference lastRequest = new AtomicReference<>(""); + private ExecutorService executor = Executors.newCachedThreadPool(); + + public MockHttpProxy(boolean acceptConnections) { + this.acceptConnections = acceptConnections; + } + + public void setRequireAuth(String username, String password) { + this.requiredUsername = username; + this.requiredPassword = password; + } + + public void setEchoMode(boolean echo) { + this.echoMode = echo; + } + + public void start() throws IOException { + serverSocket = new ServerSocket(0); + running = true; + + serverThread = new Thread(() -> { + while (running) { + try { + Socket client = serverSocket.accept(); + executor.submit(() -> handleClient(client)); + } catch (IOException e) { + if (running) { + e.printStackTrace(); + } + } + } + }); + serverThread.start(); + } + + private void handleClient(Socket client) { + try { + client.setSoTimeout(5000); + InputStream in = client.getInputStream(); + OutputStream out = client.getOutputStream(); + + // Read HTTP request + StringBuilder request = new StringBuilder(); + int ch; + while ((ch = in.read()) != -1) { + request.append((char) ch); + if (request.toString().endsWith("\r\n\r\n")) { + break; + } + } + + lastRequest.set(request.toString()); + + // Check authentication if required + if (requiredUsername != null) { + String expectedAuth = Base64.getEncoder().encodeToString( + (requiredUsername + ":" + requiredPassword).getBytes(StandardCharsets.UTF_8)); + if (!request.toString().contains(expectedAuth)) { + out.write("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n".getBytes()); + out.flush(); + client.close(); + return; + } + } + + if (acceptConnections) { + out.write("HTTP/1.1 200 Connection Established\r\n\r\n".getBytes()); + out.flush(); + + if (echoMode) { + // Echo any received data back + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + out.flush(); + } + } + } else { + out.write("HTTP/1.1 403 Forbidden\r\n\r\n".getBytes()); + out.flush(); + } + } catch (IOException e) { + // Connection closed + } finally { + try { + client.close(); + } catch (IOException e) { + // ignore + } + } + } + + public void stop() { + running = false; + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException e) { + // ignore + } + } + if (serverThread != null) { + serverThread.interrupt(); + } + executor.shutdownNow(); + } + + public int getPort() { + return serverSocket.getLocalPort(); + } + + public String getLastRequest() { + return lastRequest.get(); + } + } + + /** + * Test local SOCKS5 server (pure Java, no Android dependencies) + */ + private static class TestLocalSocksServer { + private static final int SOCKS5_VERSION = 0x05; + private static final int SOCKS5_CMD_CONNECT = 0x01; + private static final int SOCKS5_ATYP_IPV4 = 0x01; + private static final int SOCKS5_ATYP_DOMAIN = 0x03; + private static final int SOCKS5_ATYP_IPV6 = 0x04; + + private final String remoteHost; + private final int remotePort; + private final String username; + private final String password; + + private ServerSocket serverSocket; + private ExecutorService executor; + private volatile boolean running = false; + private Thread serverThread; + private int localPort; + + public TestLocalSocksServer(String remoteHost, int remotePort, + String username, String password) { + this.remoteHost = remoteHost; + this.remotePort = remotePort; + this.username = username; + this.password = password; + } + + public boolean start() { + if (running) { + return true; + } + + try { + serverSocket = new ServerSocket(0); + localPort = serverSocket.getLocalPort(); + executor = Executors.newCachedThreadPool(); + running = true; + + serverThread = new Thread(() -> { + while (running && !Thread.currentThread().isInterrupted()) { + try { + Socket clientSocket = serverSocket.accept(); + executor.submit(() -> handleClient(clientSocket)); + } catch (IOException e) { + if (running) { + e.printStackTrace(); + } + } + } + }); + + serverThread.start(); + return true; + } catch (IOException e) { + return false; + } + } + + public void stop() { + running = false; + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException e) { + // ignore + } + serverSocket = null; + } + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + if (serverThread != null) { + serverThread.interrupt(); + serverThread = null; + } + } + + public int getPort() { + return localPort; + } + + public boolean isRunning() { + return running; + } + + private void handleClient(Socket clientSocket) { + try { + clientSocket.setSoTimeout(5000); + handleSocks5Connection(clientSocket); + } catch (IOException e) { + // Connection closed + } finally { + try { + clientSocket.close(); + } catch (IOException e) { + // ignore + } + } + } + + private void handleSocks5Connection(Socket clientSocket) throws IOException { + InputStream in = clientSocket.getInputStream(); + OutputStream out = clientSocket.getOutputStream(); + + // SOCKS5 handshake + int version = in.read(); + if (version != SOCKS5_VERSION) { + return; + } + + int numMethods = in.read(); + byte[] methods = new byte[numMethods]; + in.read(methods); + + // Reply with no authentication required + out.write(new byte[]{SOCKS5_VERSION, 0x00}); + out.flush(); + + // Read connect request + version = in.read(); + if (version != SOCKS5_VERSION) { + return; + } + + int cmd = in.read(); + if (cmd != SOCKS5_CMD_CONNECT) { + out.write(new byte[]{SOCKS5_VERSION, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); + out.flush(); + return; + } + + in.read(); // Reserved + + int addrType = in.read(); + String destHost; + int destPort; + + switch (addrType) { + case SOCKS5_ATYP_IPV4: + byte[] ipv4 = new byte[4]; + in.read(ipv4); + destHost = String.format("%d.%d.%d.%d", + ipv4[0] & 0xFF, ipv4[1] & 0xFF, ipv4[2] & 0xFF, ipv4[3] & 0xFF); + break; + case SOCKS5_ATYP_DOMAIN: + int domainLen = in.read(); + byte[] domain = new byte[domainLen]; + in.read(domain); + destHost = new String(domain, StandardCharsets.UTF_8); + break; + case SOCKS5_ATYP_IPV6: + byte[] ipv6 = new byte[16]; + in.read(ipv6); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 16; i += 2) { + if (i > 0) sb.append(':'); + sb.append(String.format("%02x%02x", ipv6[i] & 0xFF, ipv6[i + 1] & 0xFF)); + } + destHost = sb.toString(); + break; + default: + out.write(new byte[]{SOCKS5_VERSION, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); + out.flush(); + return; + } + + destPort = (in.read() << 8) | in.read(); + + // Connect through HTTP proxy + Socket proxySocket = null; + try { + proxySocket = connectThroughHttpProxy(destHost, destPort); + + // Send success response + out.write(new byte[]{ + SOCKS5_VERSION, 0x00, 0x00, 0x01, + 0, 0, 0, 0, + 0, 0 + }); + out.flush(); + + // Relay data + relayData(clientSocket, proxySocket); + + } catch (IOException e) { + // Send connection refused error + out.write(new byte[]{SOCKS5_VERSION, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); + out.flush(); + } finally { + if (proxySocket != null) { + try { + proxySocket.close(); + } catch (IOException e) { + // ignore + } + } + } + } + + private Socket connectThroughHttpProxy(String destHost, int destPort) throws IOException { + Socket proxySocket = new Socket(); + proxySocket.connect(new InetSocketAddress(remoteHost, remotePort), 10000); + + InputStream proxyIn = proxySocket.getInputStream(); + OutputStream proxyOut = proxySocket.getOutputStream(); + + // Send HTTP CONNECT request + StringBuilder request = new StringBuilder(); + request.append("CONNECT ").append(destHost).append(":").append(destPort) + .append(" HTTP/1.1\r\n"); + request.append("Host: ").append(destHost).append(":").append(destPort).append("\r\n"); + + // Add proxy authentication if needed + if (username != null && !username.isEmpty()) { + String auth = username + ":" + (password != null ? password : ""); + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + request.append("Proxy-Authorization: Basic ").append(encodedAuth).append("\r\n"); + } + + request.append("Proxy-Connection: keep-alive\r\n"); + request.append("\r\n"); + + proxyOut.write(request.toString().getBytes(StandardCharsets.UTF_8)); + proxyOut.flush(); + + // Read response + StringBuilder response = new StringBuilder(); + int ch; + while ((ch = proxyIn.read()) != -1) { + response.append((char) ch); + if (response.toString().endsWith("\r\n\r\n")) { + break; + } + } + + String responseStr = response.toString(); + if (!responseStr.contains(" 200 ")) { + proxySocket.close(); + throw new IOException("Proxy connection failed: " + responseStr.split("\r\n")[0]); + } + + return proxySocket; + } + + private void relayData(Socket client, Socket proxy) { + Thread clientToProxy = new Thread(() -> { + try { + copyStream(client.getInputStream(), proxy.getOutputStream()); + } catch (IOException e) { + // Connection closed + } + }); + + Thread proxyToClient = new Thread(() -> { + try { + copyStream(proxy.getInputStream(), client.getOutputStream()); + } catch (IOException e) { + // Connection closed + } + }); + + clientToProxy.start(); + proxyToClient.start(); + + try { + clientToProxy.join(); + proxyToClient.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void copyStream(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + out.flush(); + } + } + } +} diff --git a/build.gradle b/build.gradle index 8ff29f7f..6d75d98b 100644 --- a/build.gradle +++ b/build.gradle @@ -3,16 +3,12 @@ buildscript { repositories { google() - jcenter() - maven { - url 'https://maven.fabric.io/public' - } - + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.3' - classpath 'com.google.gms:google-services:4.2.0' - classpath 'io.fabric.tools:gradle:1.31.2' + classpath 'com.android.tools.build:gradle:8.13.2' + classpath 'com.google.gms:google-services:4.4.1' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -22,8 +18,7 @@ buildscript { allprojects { repositories { google() - jcenter() - + mavenCentral() } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 54462dc0..dcc62b07 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Dec 25 09:17:58 CST 2019 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +#Mon Jan 26 09:18:01 CST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew old mode 100644 new mode 100755