diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4efcf2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + native-tests: + name: Native Go tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: native/go.mod + cache-dependency-path: native/go.sum + + - name: Run native tests with the race detector + working-directory: native + run: go test -race ./... + + android-tests: + name: Android tests and checks + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: native/go.mod + cache-dependency-path: native/go.sum + + - name: Install Android SDK components + run: | + sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [[ ! -x "$sdkmanager" ]]; then + echo "sdkmanager is unavailable at $sdkmanager" >&2 + exit 1 + fi + yes | "$sdkmanager" --licenses >/dev/null || true + "$sdkmanager" \ + "platforms;android-35" \ + "build-tools;34.0.0" \ + "ndk;29.0.14206865" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" + + - name: Install gomobile + run: | + go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20260821190718-4776eadac327 + gomobile init + + - name: Build arm64 Android AAR + working-directory: native + run: | + mkdir -p ../app/libs + gomobile bind \ + -target=android/arm64 \ + -androidapi 26 \ + -trimpath \ + -o ../app/libs/megaproxy.aar \ + ./mobile + + - name: Run Android unit tests, lint, and debug build + run: ./gradlew --no-daemon testDebugUnitTest lintDebug assembleDebug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5041f13 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,106 @@ +name: Release APKs + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Build and publish signed APKs + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Check out release tag + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: native/go.mod + cache-dependency-path: native/go.sum + + - name: Install Android SDK components + run: | + sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [[ ! -x "$sdkmanager" ]]; then + echo "sdkmanager is unavailable at $sdkmanager" >&2 + exit 1 + fi + yes | "$sdkmanager" --licenses >/dev/null || true + "$sdkmanager" \ + "platforms;android-35" \ + "build-tools;34.0.0" \ + "ndk;29.0.14206865" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" + + - name: Install gomobile + run: | + go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20260821190718-4776eadac327 + gomobile init + + - name: Validate release tag + run: | + version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' app/build.gradle.kts | head -n 1)" + expected_tag="v${version_name}" + if [[ -z "$version_name" || "$GITHUB_REF_NAME" != "$expected_tag" ]]; then + echo "Release tag $GITHUB_REF_NAME does not match app version $expected_tag" >&2 + exit 1 + fi + + - name: Restore Android signing key + env: + SIGNING_KEY_BASE64: ${{ secrets.ANDROID_SIGNING_KEY_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + run: | + if [[ -z "$SIGNING_KEY_BASE64" || -z "$KEYSTORE_PASSWORD" ]]; then + echo "Android signing secrets are not configured" >&2 + exit 1 + fi + keystore="$RUNNER_TEMP/megaproxy-release.p12" + password_file="$RUNNER_TEMP/megaproxy-keystore-password" + printf '%s' "$SIGNING_KEY_BASE64" | base64 --decode > "$keystore" + printf '%s' "$KEYSTORE_PASSWORD" > "$password_file" + chmod 600 "$keystore" "$password_file" + echo "MEGAPROXY_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV" + echo "MEGAPROXY_KEY_PASSWORD_FILE=$password_file" >> "$GITHUB_ENV" + + - name: Build optimized signed APKs + env: + MEGAPROXY_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + MEGAPROXY_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + MEGAPROXY_GIT_COMMIT: ${{ github.sha }} + run: ./scripts/build-release-apks.sh + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + assets=(dist/release/*.apk dist/release/SHA256SUMS) + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "${assets[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + else + gh release create "$GITHUB_REF_NAME" "${assets[@]}" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --generate-notes \ + --title "MegaProxy ${GITHUB_REF_NAME}" + fi diff --git a/.gitignore b/.gitignore index e5cbb64..847df0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,15 @@ -# Gradle files .gradle/ -build/ - -# Local configuration file (sdk path, etc) +.idea/ local.properties - -# Log/OS Files -*.log - -# Android Studio generated files and folders -captures/ -.externalNativeBuild/ -.cxx/ -*.aab -*.apk -output-metadata.json - -# IntelliJ +**/build/ *.iml -.idea/ -misc.xml -deploymentTargetDropDown.xml -render.experimental.xml - -# Keystore files +.DS_Store +*.apk +*.aab *.jks *.keystore - -# Google Services (e.g. APIs or Firebase) -google-services.json - -# Android Profiling -*.hprof +/dist/ +.externalNativeBuild/ +.cxx/ +native/*.aar +app/libs/*.aar diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..3fefce4 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "vscjava.vscode-java-pack", + "fwcd.kotlin", + "golang.go" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a17cd2d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node-terminal", + "name": "Run MegaProxy on Emulator", + "request": "launch", + "command": "${workspaceFolder}/scripts/run-without-debugging.sh", + "cwd": "${workspaceFolder}" + }, + { + "type": "java", + "name": "Attach MegaProxy (JDWP)", + "request": "attach", + "hostName": "127.0.0.1", + "port": 8700, + "timeout": 60000, + "sourcePaths": [ + "${workspaceFolder}/app/src/main/java" + ], + "preLaunchTask": "Android: Prepare JDWP" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..2d0c53f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,148 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Android: Create emulator", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; ./scripts/create-android-emulator.sh", + "problemMatcher": [] + }, + { + "label": "Android: List emulators", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; emulator -list-avds", + "problemMatcher": [] + }, + { + "label": "Android: Start emulator", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; emulator @MegaProxy_API_35 -gpu auto", + "isBackground": true, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Android: Run on emulator", + "type": "shell", + "command": "./scripts/run-without-debugging.sh", + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Android: Build native AAR", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; mkdir -p ../app/libs; gomobile bind -target=android -androidapi 26 -o ../app/libs/megaproxy.aar ./mobile", + "options": { + "cwd": "${workspaceFolder}/native" + }, + "problemMatcher": [] + }, + { + "label": "Test: Go core", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; go test ./...", + "options": { + "cwd": "${workspaceFolder}/native" + }, + "group": "test", + "problemMatcher": [] + }, + { + "label": "Test: Android unit tests", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; ./gradlew testDebugUnitTest", + "group": "test", + "problemMatcher": [] + }, + { + "label": "Test: All", + "dependsOn": [ + "Test: Go core", + "Test: Android unit tests" + ], + "dependsOrder": "parallel", + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Android: Build debug APK", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; ./gradlew assembleDebug", + "dependsOn": ["Android: Build native AAR"], + "dependsOrder": "sequence", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Android: Install debug APK", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; adb wait-for-device; adb install -r app/build/outputs/apk/debug/app-debug.apk", + "dependsOn": ["Android: Build debug APK"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "Android: Launch app", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; adb shell am start -n net.megaproxy487/.MainActivity", + "dependsOn": ["Android: Install debug APK"], + "dependsOrder": "sequence", + "problemMatcher": [] + }, + { + "label": "Android: Logcat (app)", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; ./scripts/android-logcat.sh", + "isBackground": true, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Android: Prepare JDWP", + "type": "shell", + "command": "MEGAPROXY_JDWP_KEEP_ALIVE=1 ./scripts/debug-android-app.sh", + "isBackground": true, + "problemMatcher": [ + { + "owner": "megaproxy-jdwp", + "pattern": { + "regexp": "^(?!)$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^Starting MegaProxy JDWP setup$", + "endsPattern": "^JDWP ready:" + } + } + ], + "presentation": { + "reveal": "always", + "focus": true, + "panel": "dedicated", + "clear": true, + "showReuseMessage": false + } + }, + { + "label": "Android: Clear JDWP", + "type": "shell", + "command": "source \"$HOME/.zshrc.extra\"; adb -e shell am clear-debug-app; adb -e forward --remove tcp:8700 || true", + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md index 0f077d1..febd0d2 100644 --- a/README.md +++ b/README.md @@ -1 +1,245 @@ -# AndroidMegaProxy \ No newline at end of file +# MegaProxy + +[![CI](https://github.com/andre487/AndroidMegaProxy/actions/workflows/ci.yml/badge.svg)](https://github.com/andre487/AndroidMegaProxy/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Android 8+](https://img.shields.io/badge/Android-8.0%2B-3DDC84?logo=android&logoColor=white)](https://developer.android.com/about/versions/oreo) + +

+ MegaProxy app icon +

+ +MegaProxy is an open-source Android VPN client for reliable, secure connections through proxy +servers you control or trust. It supports HTTPS and SSH transports, per-app routing, encrypted DNS, +connection diagnostics, and automatic failover in one privacy-focused application. + +MegaProxy contains no advertising, analytics SDKs, tracking, or remote telemetry. Connection +statistics and diagnostic logs stay on the device unless you explicitly choose to share them. + +> MegaProxy is under active development. Review the current limitations before relying on it for +> critical connectivity. + +## Why MegaProxy + +- **Private by design.** No account, ads, analytics, tracking identifiers, or background telemetry. +- **Your infrastructure.** Connect to HTTPS, SSH, or SSH-with-jump servers that you configure. +- **End-to-end application encryption.** HTTPS proxying uses CONNECT without intercepting or + decrypting application traffic. +- **Flexible routing.** Route the whole device or only selected applications through the VPN. +- **Resilient connections.** Profile failover, encrypted DNS fallback, SSH keepalives, and + connection health reporting help recover from network and server failures. +- **Fully open source.** The Android application and Go network core are available under the MIT + License. + +## Features + +### Connection profiles + +- Multiple named, colored, reorderable profiles. +- HTTPS proxies over TLS with Basic authentication. +- HTTP/2 CONNECT multiplexing when supported by the proxy, with automatic HTTP/1.1 fallback. +- SSH `direct-tcpip` transport and SSH through a jump host. +- SSH password and unencrypted private-key authentication. +- SSH host-key verification with trust-on-first-use confirmation. +- Profile cloning, import, export, and configurable failover. +- Import from MegaProxy JSON, ProxyList, FoxyProxy JSON, and supported SuperProxy text files. + +### VPN and routing + +- Global VPN and per-app split tunneling. +- Local-network bypass enabled by default. +- Per-profile IPv6 support; IPv4-only operation is the default. +- Android Always-on VPN integration and a persistent foreground-service notification. +- Automatic reconnect when the active profile or pending connection settings change. +- Approximate upload speed, download speed, proxy latency, and recent connection-error rate. + +### DNS and transport + +- DNS-over-HTTPS through the configured transport. +- Cloudflare, Google, Quad9, Yandex Basic, Yandex Safe, Yandex Family, and custom DoH endpoints. +- DNS-provider fallback where it does not weaken an explicitly selected filtering policy. +- HTTPS ClientHello profiles powered by uTLS, plus manual JA3 configuration. +- Configurable SSH client profiles, keepalives, channel limits, and session rotation. +- Arbitrary UDP is intentionally not forwarded; QUIC/HTTP/3 clients normally fall back to TCP. + +### Diagnostics + +- A staged connection test for proxy setup, `example.com`, and the observed exit IP. +- Local, size-limited, rotating diagnostic and crash logs designed to omit credentials and traffic + content. +- On-device connection visibility checks and actionable connection warnings. +- Optional feedback or crash reports opened in the user's email client; nothing is submitted + automatically. +- English and Russian interfaces with an in-app language selector. + +## Privacy and security + +MegaProxy does not operate a proxy service and does not send configuration or usage data to the +project author. Network traffic is sent only where required by the selected profile, destination, +and DNS configuration. The explicit connection test additionally contacts `example.com` and +`ifconfig.me`. + +- HTTPS proxy certificates are checked against the Android trust store, including hostname and + validity. Normal CA certificate renewal does not require certificate pinning. +- Application TLS remains between the application and its destination. MegaProxy does not install + a CA certificate and does not perform TLS interception. +- Proxy passwords and imported private keys are encrypted with AES-GCM using a key held by Android + Keystore. +- Android cloud backup and device-to-device transfer are disabled for application data. +- Every upstream socket is protected from recursive routing through the VPN. +- SSH host keys are verified and unknown keys require explicit user confirmation. + +Two compatibility options deliberately reduce these protections: accepting an invalid HTTPS +proxy certificate and accepting any SSH host key. MegaProxy displays a warning before enabling +them. Use either option only when you understand and control the associated risk. + +The proxy or SSH server can observe connection metadata and the destinations it is asked to reach, +even though it cannot read end-to-end encrypted application content. The operator of that server +must therefore be trusted. No application can override an explicit Android force-stop, and device +vendors may impose additional background-execution restrictions. + +## Installation + +Download an APK for your device architecture from +[GitHub Releases](https://github.com/andre487/AndroidMegaProxy/releases). Most modern Android +phones use `arm64-v8a`. Android 8.0 (API 26) or newer is required. + +Release APKs are signed by the project's release key. SHA-256 checksums are published alongside +the APK files. Android will preserve application data across upgrades only when the package name +and signing key remain unchanged. + +After installation: + +1. Create or import a connection profile. +2. Choose global routing or select applications for split tunneling. +3. Review DNS and fingerprint settings if the defaults are not appropriate for your server. +4. Tap **Test** to validate the connection, then tap **Connect**. +5. Optionally enable Always-on VPN in Android settings. + +For production-ready HTTPS, SSH, and SSH-with-jump server configurations, see the +[English server guide](docs/en/index.md) or [Russian server guide](docs/ru/index.md). + +## Current limitations + +- Only TCP application traffic is forwarded. General SOCKS5 UDP and QUIC forwarding are not + implemented. +- SSH private keys protected by a passphrase are not supported yet. +- Browser and SSH fingerprint presets are version-specific approximations. A preset name is not a + permanent guarantee of an exact client fingerprint. +- Edge Android, Samsung Internet, and Yandex Browser TLS presets remain unavailable until verified + Android ClientHello fixtures are added. +- Always-on behavior ultimately depends on Android and the device vendor. An explicit force-stop + cannot be recovered from programmatically. + +## Building from source + +The command-line build does not require Android Studio. It requires: + +- JDK 17 +- Go 1.26 or newer +- `gomobile` +- Android SDK Platform 35 +- Android Build Tools 34.0.0 +- Android NDK 29.0.14206865 + +Example environment on macOS: + +```shell +export JAVA_HOME="/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home" +export ANDROID_HOME="$HOME/Library/Android/sdk" +export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" +export PATH="$JAVA_HOME/bin:$HOME/go/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH" +``` + +Build the Go core and Android AAR: + +```shell +cd native +go test ./... +gomobile bind -target=android -androidapi 26 -o ../app/libs/megaproxy.aar ./mobile +cd .. +``` + +Build and test the Android application: + +```shell +./gradlew testDebugUnitTest assembleDebug +``` + +Install the debug APK on a connected device: + +```shell +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n net.megaproxy487/.MainActivity +``` + +The project pins Gradle 8.9 through the checked-in wrapper. Use `./gradlew` rather than a globally +installed Gradle version. See [native/README.md](native/README.md) for Go data-plane details. + +## Development workflow + +English is the project language for source code, comments, documentation, commit messages, UI +copy, logs, and tooling. + +### Emulator + +Create the API 35 Google APIs ARM64 emulator: + +```shell +./scripts/create-android-emulator.sh +``` + +The script installs missing components, configures host keyboard and mouse input, and can be run +more than once. It creates `MegaProxy_API_35` by default; set `MEGAPROXY_AVD_NAME` to override the +name. + +Start the application without a debugger: + +```shell +./scripts/run-without-debugging.sh +``` + +The repository also includes VS Code tasks and launch configurations for creating and starting the +emulator, building, installing, viewing app-specific Logcat output, running tests, and attaching a +JDWP debugger. Select **Run MegaProxy on Emulator** for `Ctrl+F5` or **Attach MegaProxy (JDWP)** for +`F5`. + +Run all native and Android unit tests from VS Code with **Tasks: Run Test Task**, or from a shell: + +```shell +(cd native && go test ./...) +./gradlew testDebugUnitTest +``` + +JDWP covers Kotlin and Java code only. Debug the Go core with its tests and privacy-safe diagnostic +logging. + +### Signed release builds + +Build optimized and signed APKs for `arm64-v8a`, `armeabi-v7a`, `x86_64`, and `x86`: + +```shell +./scripts/build-release-apks.sh +``` + +The script reads its default signing key from `$HOME/AndroidApkKey` and its password from +`$HOME/.my-tokens/android-key-password`. Override these with `MEGAPROXY_KEYSTORE_PATH`, +`MEGAPROXY_KEY_ALIAS`, `MEGAPROXY_KEY_PASSWORD_FILE`, and `MEGAPROXY_KEY_PASSWORD`. Outputs and +`SHA256SUMS` are written to `dist/release`. + +Pushing a version tag runs the GitHub release workflow, builds and verifies every APK, and attaches +the artifacts to a GitHub Release. The tag must match `versionName` exactly: + +```shell +git tag v0.0.1 +git push origin v0.0.1 +``` + +## Contributing + +Bug reports and focused pull requests are welcome. Please avoid including proxy credentials, +private keys, destination history, or other personal data in issues and logs. Run both the Go and +Android unit-test suites before opening a pull request. + +## License + +MegaProxy is released under the [MIT License](LICENSE). diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..7a43fbf --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,86 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +val releaseKeystorePath = providers.environmentVariable("MEGAPROXY_KEYSTORE_PATH").orNull +val releaseKeystorePassword = providers.environmentVariable("MEGAPROXY_KEYSTORE_PASSWORD").orNull +val releaseKeyAlias = providers.environmentVariable("MEGAPROXY_KEY_ALIAS").orNull +val releaseKeyPassword = providers.environmentVariable("MEGAPROXY_KEY_PASSWORD").orNull +val gitCommitHash = providers.environmentVariable("GITHUB_SHA") + .orElse(providers.environmentVariable("MEGAPROXY_GIT_COMMIT")) + .orElse(providers.exec { + commandLine("git", "rev-parse", "--short=8", "HEAD") + isIgnoreExitValue = true + }.standardOutput.asText) + .map { value -> + value.trim().take(8).takeIf { it.matches(Regex("[0-9a-fA-F]{7,8}")) } ?: "unknown" + } + .get() + +android { + namespace = "net.megaproxy487" + compileSdk = 35 + + defaultConfig { + applicationId = "net.megaproxy487" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.0.1" + buildConfigField("String", "GIT_COMMIT_HASH", "\"$gitCommitHash\"") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + if (releaseKeystorePath != null && releaseKeystorePassword != null && + releaseKeyAlias != null && releaseKeyPassword != null + ) { + create("release") { + storeFile = file(releaseKeystorePath) + storePassword = releaseKeystorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + } + } + } + buildTypes { + getByName("release") { + signingConfig = signingConfigs.findByName("release") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + buildFeatures { + compose = true + buildConfig = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" } + // Both bundled locales must remain available to the in-app language selector. + bundle { language { enableSplit = false } } +} + +kotlin { jvmToolchain(17) } + +dependencies { + implementation(platform("androidx.compose:compose-bom:2025.01.01")) + implementation("androidx.activity:activity-compose:1.10.0") + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20250107") + runtimeOnly(files("libs/megaproxy.aar")) +} diff --git a/app/libs/megaproxy-sources.jar b/app/libs/megaproxy-sources.jar new file mode 100644 index 0000000..4f2a5d7 Binary files /dev/null and b/app/libs/megaproxy-sources.jar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..410fd0a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Project-specific release rules. The gomobile AAR supplies its own keep rules +# for the generated Go and JNI bridge classes. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5ed3be8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/net/megaproxy487/BatteryOptimizationReminder.kt b/app/src/main/java/net/megaproxy487/BatteryOptimizationReminder.kt new file mode 100644 index 0000000..eba5336 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/BatteryOptimizationReminder.kt @@ -0,0 +1,33 @@ +package net.megaproxy487 + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings + +object BatteryOptimizationReminder { + private const val PREFS_NAME = "battery_optimization_reminder" + private const val LAST_REQUEST_AT = "last_request_at" + internal const val REQUEST_INTERVAL_MS = 24 * 60 * 60 * 1000L + + fun maybeRequest(activity: Activity, now: Long = System.currentTimeMillis()) { + val powerManager = activity.getSystemService(PowerManager::class.java) + if (powerManager.isIgnoringBatteryOptimizations(activity.packageName)) return + + val preferences = activity.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val lastRequestAt = preferences.getLong(LAST_REQUEST_AT, 0L) + if (!isRequestDue(now, lastRequestAt)) return + + val request = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${activity.packageName}") + } + if (runCatching { activity.startActivity(request) }.isSuccess) { + preferences.edit().putLong(LAST_REQUEST_AT, now).apply() + } + } + + internal fun isRequestDue(now: Long, lastRequestAt: Long): Boolean = + lastRequestAt == 0L || now < lastRequestAt || now - lastRequestAt >= REQUEST_INTERVAL_MS +} diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt b/app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt new file mode 100644 index 0000000..53c2914 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt @@ -0,0 +1,245 @@ +package net.megaproxy487 + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.VpnService +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.vpn.TestDiagnosticLog +import net.megaproxy487.vpn.TestState +import net.megaproxy487.vpn.SshHostKeyPromptState +import net.megaproxy487.vpn.OTHER_ALWAYS_ON_VPN_MESSAGE +import net.megaproxy487.vpn.hasOtherProvider +import net.megaproxy487.vpn.openAndroidVpnSettings +import net.megaproxy487.vpn.readAlwaysOnVpnStatus +import net.megaproxy487.ui.theme.MegaProxyTheme + +class ConnectionTestActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val autoStart = savedInstanceState == null + if (autoStart) TestDiagnosticLog.reset() + setContent { MegaProxyTheme { ConnectionTestScreen(this, autoStart) } } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConnectionTestScreen(activity: Activity, autoStart: Boolean) { + val state by TestDiagnosticLog.state + val exitIp by TestDiagnosticLog.exitIp + val pendingHostKey by SshHostKeyPromptState.pending + var vpnPermissionRequestedAt by remember { mutableStateOf(0L) } + var showAlwaysOnConflict by remember { mutableStateOf(false) } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (VpnService.prepare(activity) == null) ProxyVpnService.test(activity) + else { + val status = readAlwaysOnVpnStatus(activity) + val dismissedImmediately = System.currentTimeMillis() - vpnPermissionRequestedAt < 1_000 + if (status.hasOtherProvider || dismissedImmediately) { + TestDiagnosticLog.fail(OTHER_ALWAYS_ON_VPN_MESSAGE) + showAlwaysOnConflict = true + } else { + TestDiagnosticLog.fail("VPN access was not granted in the Android confirmation dialog") + } + } + } + val runTest = { + val configStore = ConfigStore(activity) + val error = configStore.globalConnectionSettings().applyTo(configStore.activeProfile().config) + .connectionValidationError() + if (error != null) { + TestDiagnosticLog.fail(error) + } else { + TestDiagnosticLog.begin() + val status = readAlwaysOnVpnStatus(activity) + if (status.hasOtherProvider) { + TestDiagnosticLog.fail(OTHER_ALWAYS_ON_VPN_MESSAGE) + showAlwaysOnConflict = true + } else { + val intent = VpnService.prepare(activity) + if (intent == null) { + ProxyVpnService.test(activity) + } else { + vpnPermissionRequestedAt = System.currentTimeMillis() + permission.launch(intent) + } + } + } + } + LaunchedEffect(autoStart) { if (autoStart) runTest() } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.connection_test)) }, + navigationIcon = { + IconButton(onClick = { activity.finish() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + contentWindowInsets = WindowInsets.safeDrawing, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val statusLabel = when (state) { + TestState.IDLE -> "Ready" + TestState.RUNNING -> "Testing" + TestState.SUCCEEDED -> "Test passed" + TestState.FAILED -> "Test failed" + } + Card(Modifier.fillMaxWidth().semantics { + liveRegion = LiveRegionMode.Polite + stateDescription = statusLabel + }) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val statusColor = when (state) { + TestState.SUCCEEDED -> MaterialTheme.colorScheme.primary + TestState.FAILED -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + } + Text( + if (state == TestState.RUNNING) "Testing…" else statusLabel, + style = MaterialTheme.typography.titleLarge, + color = statusColor, + ) + if (state == TestState.RUNNING) { + CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) + } + exitIp?.let { Text("Proxy exit IP: $it") } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = runTest, enabled = state != TestState.RUNNING) { Text(stringResource(R.string.run_again)) } + Button(onClick = { + val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("MegaProxy connection test", TestDiagnosticLog.entries.joinToString("\n"))) + }, enabled = TestDiagnosticLog.entries.isNotEmpty()) { Text(stringResource(R.string.copy_log)) } + } + Text( + "The test uses a temporary VPN when the main connection is inactive. Credentials and traffic content are not logged.", + style = MaterialTheme.typography.bodySmall, + ) + Box(Modifier.fillMaxWidth().weight(1f)) { + SelectionContainer { + Text( + TestDiagnosticLog.entries.joinToString("\n").ifEmpty { + if (state == TestState.RUNNING) "Waiting for the first diagnostic event…" else "No test events yet." + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) + } + } + } + } + + if (showAlwaysOnConflict) { + AlertDialog( + onDismissRequest = { showAlwaysOnConflict = false }, + title = { Text("Always-on VPN is already in use") }, + text = { Text(OTHER_ALWAYS_ON_VPN_MESSAGE) }, + confirmButton = { + TextButton(onClick = { + showAlwaysOnConflict = false + openAndroidVpnSettings(activity) + }) { Text("Change settings") } + }, + dismissButton = { + TextButton(onClick = { showAlwaysOnConflict = false }) { Text("Cancel") } + }, + ) + } + pendingHostKey?.takeIf { it.testOnly }?.let { pending -> + AlertDialog( + onDismissRequest = { + SshHostKeyPromptState.clear() + ProxyVpnService.dismissHostKeyPrompt(activity) + }, + title = { Text(if (pending.changed) "SSH host key changed" else "Trust SSH host key?") }, + text = { Text(buildString { + if (pending.changed) { + append("Warning: the previously trusted key for the ${pending.hop} host has changed. This may indicate a server reinstall or a man-in-the-middle attack. Verify it through a trusted channel before replacing it.\n\n") + } else { + append("This is the first connection to the ${pending.hop} SSH host. Verify its fingerprint through a trusted channel.\n\n") + } + append("Algorithm: ${pending.algorithm}\nFingerprint: ${pending.fingerprint}") + }) }, + confirmButton = { + TextButton(onClick = { + val saved = ConfigStore(activity).trustSshHostKey( + pending.profileId, pending.hop, pending.fingerprint, + ) + SshHostKeyPromptState.clear() + val persisted = ConfigStore(activity).profile(pending.profileId)?.config?.let { config -> + if (pending.hop == "jump") config.jumpTrustedHostKey else config.trustedHostKey + } + if (saved && persisted == pending.fingerprint) { + ProxyVpnService.test(activity) + } else { + TestDiagnosticLog.fail("The SSH host key could not be saved to the tested profile") + } + }) { Text(if (pending.changed) "Replace and test" else "Trust and test") } + }, + dismissButton = { + TextButton(onClick = { + SshHostKeyPromptState.clear() + ProxyVpnService.dismissHostKeyPrompt(activity) + }) { Text("Cancel") } + }, + ) + } +} diff --git a/app/src/main/java/net/megaproxy487/CrashHandler.kt b/app/src/main/java/net/megaproxy487/CrashHandler.kt new file mode 100644 index 0000000..d1c148e --- /dev/null +++ b/app/src/main/java/net/megaproxy487/CrashHandler.kt @@ -0,0 +1,35 @@ +package net.megaproxy487 + +import android.content.Context +import android.content.SharedPreferences +import android.os.Process +import net.megaproxy487.vpn.PersistentDiagnosticLog +import kotlin.system.exitProcess + +object CrashHandler { + private const val PREFS = "crash_state" + private const val PENDING = "pending" + private lateinit var preferences: SharedPreferences + + fun install(context: Context) { + preferences = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + runCatching { + PersistentDiagnosticLog.writeCrash(thread, throwable) + preferences.edit().putBoolean(PENDING, true).commit() + } + if (previous != null) previous.uncaughtException(thread, throwable) else { + Process.killProcess(Process.myPid()) + exitProcess(10) + } + } + } + + fun hasPendingReport(): Boolean = + ::preferences.isInitialized && preferences.getBoolean(PENDING, false) + + fun markReportHandled() { + if (::preferences.isInitialized) preferences.edit().putBoolean(PENDING, false).apply() + } +} diff --git a/app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt b/app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt new file mode 100644 index 0000000..bba768d --- /dev/null +++ b/app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt @@ -0,0 +1,209 @@ +package net.megaproxy487 + +import android.app.Activity +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.vpn.PersistentDiagnosticLog +import net.megaproxy487.ui.theme.MegaProxyTheme + +class DiagnosticLogActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MegaProxyTheme { DiagnosticLogScreen(this) } } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DiagnosticLogScreen(activity: Activity) { + val viewerWindowBytes = 512 * 1024 + val store = remember { ConfigStore(activity) } + val listState = rememberLazyListState() + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + var lines by remember { mutableStateOf(emptyList()) } + var autoScroll by remember { mutableStateOf(true) } + var limitText by remember { mutableStateOf(store.diagnosticLogLimitMb().toString()) } + var showClearConfirmation by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + val isAtBottom by remember { + derivedStateOf { + lines.isEmpty() || listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == lines.lastIndex + } + } + + val exportDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri != null) { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + activity.contentResolver.openOutputStream(uri, "wt")?.buffered()?.use { + PersistentDiagnosticLog.copyTo(it) + } ?: error("Could not open the export file") + } + }.onSuccess { message = "Diagnostic log exported." } + .onFailure { message = it.message ?: "Could not export the diagnostic log" } + } + } + } + + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + val content = withContext(Dispatchers.IO) { PersistentDiagnosticLog.readTail(viewerWindowBytes) } + lines = content.lineSequence().filter(String::isNotEmpty).toList() + delay(1_000) + } + } + } + LaunchedEffect(listState) { + listState.interactionSource.interactions.collect { interaction -> + if (interaction is DragInteraction.Start) autoScroll = false + } + } + LaunchedEffect(isAtBottom) { + if (isAtBottom) autoScroll = true + } + LaunchedEffect(lines.size, autoScroll) { + if (autoScroll && lines.isNotEmpty()) listState.scrollToItem(lines.lastIndex) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.diagnostic_log)) }, + navigationIcon = { + IconButton(onClick = { activity.finish() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = limitText, + onValueChange = { value -> + if (value.all(Char::isDigit) && value.length <= 3) { + limitText = value + value.toIntOrNull()?.takeIf { + it in PersistentDiagnosticLog.MIN_LIMIT_MB..PersistentDiagnosticLog.MAX_LIMIT_MB + }?.let { + store.setDiagnosticLogLimitMb(it) + PersistentDiagnosticLog.setLimitMb(it) + } + } + }, + label = { Text("Rotated log limit (MB)") }, + supportingText = { Text("Total size across both log segments; 1–100 MB.") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { exportDocument.launch("MegaProxy-diagnostic.log") }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.export_action)) + } + OutlinedButton(onClick = { showClearConfirmation = true }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.clear_action)) + } + } + Text( + buildString { + append(if (autoScroll) "Auto-scroll on" else "Auto-scroll paused · scroll to the bottom to resume") + append(" · showing the latest 512 KB") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().weight(1f), + ) { + if (lines.isEmpty()) item { Text("No diagnostic events yet.") } + itemsIndexed(lines, key = { index, _ -> index }) { _, line -> + Text(line, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } + } + } + } + + if (showClearConfirmation) { + AlertDialog( + onDismissRequest = { showClearConfirmation = false }, + title = { Text("Clear diagnostic log?") }, + text = { Text("Both rotated log segments will be deleted.") }, + confirmButton = { TextButton(onClick = { + showClearConfirmation = false + PersistentDiagnosticLog.clear() + }) { Text("Clear") } }, + dismissButton = { TextButton(onClick = { showClearConfirmation = false }) { Text("Cancel") } }, + ) + } + message?.let { + AlertDialog( + onDismissRequest = { message = null }, + text = { Text(it) }, + confirmButton = { TextButton(onClick = { message = null }) { Text("OK") } }, + ) + } +} diff --git a/app/src/main/java/net/megaproxy487/FeedbackEmail.kt b/app/src/main/java/net/megaproxy487/FeedbackEmail.kt new file mode 100644 index 0000000..bdce2ab --- /dev/null +++ b/app/src/main/java/net/megaproxy487/FeedbackEmail.kt @@ -0,0 +1,99 @@ +package net.megaproxy487 + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.vpn.DiagnosticLog +import net.megaproxy487.vpn.PersistentDiagnosticLog +import net.megaproxy487.vpn.VpnConnectionState +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import java.util.UUID + +object FeedbackEmail { + private const val ADDRESS = "megaproxy-feedback@hotmail.com" + private const val SUBJECT = "[MegaProxy] Feedback" + + fun createIntent( + context: Context, + connection: VpnConnectionState, + alwaysOn: Boolean, + lockdown: Boolean, + crashReport: Boolean = false, + ): Intent { + val store = ConfigStore(context) + val config = store.globalConnectionSettings().applyTo(store.connectionProfile().config) + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val versionCode = if (Build.VERSION.SDK_INT >= 28) packageInfo.longVersionCode else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + val versionName = packageInfo.versionName ?: "unknown" + val body = buildString { + appendLine(if (crashReport) "Please describe what happened before the crash above this line." else "Please write your comments above this line.") + appendLine() + appendLine("--- Diagnostic information ---") + appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") + appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") + if (Build.VERSION.SECURITY_PATCH.isNotBlank()) appendLine("Security patch: ${Build.VERSION.SECURITY_PATCH}") + appendLine("App: $versionName ($versionCode)") + appendLine("ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}") + appendLine("Connection: ${connection.name.lowercase()}") + appendLine("Always-on: $alwaysOn; lockdown: $lockdown") + appendLine("Routing: ${if (config.routeAllApps) "global" else "split"}; selected app count: ${config.selectedPackages.size}") + appendLine("HTTPS fingerprint: ${config.profile.name}") + appendLine("SSH fingerprint: ${config.sshProfile.name}") + appendLine("DoH provider: ${config.dnsProvider.name}") + appendLine("IPv6: ${config.allowIpv6}; bypass local networks: ${config.bypassLocalNetworks}") + appendLine("Proxy certificate verification: ${!config.allowInvalidProxyCertificate}") + appendLine("Privacy-filtered logs are attached.") + } + + val directory = File(context.cacheDir, "feedback").also { it.mkdirs() } + directory.listFiles { file -> file.isFile && file.extension == "zip" } + .orEmpty() + .sortedByDescending(File::lastModified) + .drop(2) + .forEach(File::delete) + // Do not reuse a content URI that may still be granted to an email app: + // a stale grant must never expose diagnostics generated in the future. + val logFile = File(directory, "megaproxy-diagnostics-${UUID.randomUUID()}.zip") + DiagnosticLog.add("event=feedback result=prepared") + ZipOutputStream(logFile.outputStream().buffered()).use { zip -> + zip.putNextEntry(ZipEntry("megaproxy-diagnostic.log")) + PersistentDiagnosticLog.copyTo(zip) + zip.closeEntry() + } + val logUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", logFile) + + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_EMAIL, arrayOf(ADDRESS)) + putExtra(Intent.EXTRA_SUBJECT, if (crashReport) "[MegaProxy] Crash" else SUBJECT) + putExtra(Intent.EXTRA_TEXT, body) + putExtra(Intent.EXTRA_STREAM, logUri) + clipData = ClipData.newUri(context.contentResolver, "MegaProxy diagnostic log archive", logUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val emailPackages = context.packageManager + .queryIntentActivities(Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$ADDRESS")), 0) + .map { it.activityInfo.packageName } + .distinct() + val targeted = emailPackages.mapNotNull { packageName -> + Intent(sendIntent).setPackage(packageName).takeIf { + it.resolveActivity(context.packageManager) != null + } + } + if (targeted.isEmpty()) return Intent.createChooser(sendIntent, "Send feedback") + return Intent.createChooser(targeted.first(), "Send feedback").apply { + if (targeted.size > 1) { + putExtra(Intent.EXTRA_INITIAL_INTENTS, targeted.drop(1).toTypedArray()) + } + } + } +} diff --git a/app/src/main/java/net/megaproxy487/LocalizedActivity.kt b/app/src/main/java/net/megaproxy487/LocalizedActivity.kt new file mode 100644 index 0000000..b8ca5cd --- /dev/null +++ b/app/src/main/java/net/megaproxy487/LocalizedActivity.kt @@ -0,0 +1,60 @@ +package net.megaproxy487 + +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import android.os.LocaleList +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import java.util.Locale + +enum class AppLanguage(val tag: String) { + ENGLISH("en"), + RUSSIAN("ru"), +} + +object AppLanguageManager { + private const val PREFERENCES = "app_language" + private const val KEY_LANGUAGE = "language" + + fun current(context: Context): AppLanguage { + val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + val stored = preferences.getString(KEY_LANGUAGE, null) + if (stored != null) return AppLanguage.entries.firstOrNull { it.tag == stored } ?: AppLanguage.ENGLISH + val initial = if (Locale.getDefault().language == AppLanguage.RUSSIAN.tag) { + AppLanguage.RUSSIAN + } else { + AppLanguage.ENGLISH + } + preferences.edit().putString(KEY_LANGUAGE, initial.tag).apply() + return initial + } + + fun set(context: Context, language: AppLanguage) { + context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_LANGUAGE, language.tag) + .apply() + } + + fun wrap(context: Context): Context { + val locale = Locale.forLanguageTag(current(context).tag) + val configuration = Configuration(context.resources.configuration) + configuration.setLocales(LocaleList(locale)) + configuration.setLayoutDirection(locale) + return context.createConfigurationContext(configuration) + } +} + +abstract class LocalizedActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Keep every screen on the same backward-compatible edge-to-edge path. + // Individual Compose roots remain responsible for consuming system and IME insets. + enableEdgeToEdge() + } + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(AppLanguageManager.wrap(newBase)) + } +} diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt new file mode 100644 index 0000000..74d5145 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -0,0 +1,673 @@ +package net.megaproxy487 + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.VpnService +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.outlined.RadioButtonUnchecked +import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.foundation.clickable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import net.megaproxy487.vpn.ConnectionStatsReader +import net.megaproxy487.vpn.NativeConnectionStats +import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.vpn.SshHostKeyPromptState +import net.megaproxy487.vpn.VpnConnectionState +import net.megaproxy487.vpn.VpnRuntimeState +import net.megaproxy487.vpn.VpnTransportProtocol +import net.megaproxy487.vpn.readAlwaysOnVpnStatus +import net.megaproxy487.vpn.AlwaysOnVpnStatus +import net.megaproxy487.vpn.hasOtherProvider +import net.megaproxy487.vpn.openAndroidVpnSettings +import net.megaproxy487.vpn.OTHER_ALWAYS_ON_VPN_MESSAGE +import net.megaproxy487.model.ProfileColors +import net.megaproxy487.model.ProxyType +import net.megaproxy487.model.ProxyProfile +import net.megaproxy487.ui.theme.MegaProxyTheme + +class MainActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + BatteryOptimizationReminder.maybeRequest(this) + setContent { MegaProxyTheme { MainScreen(this) } } + } + + override fun onResume() { + super.onResume() + ProxyVpnService.refreshStatus(this) + val status = readAlwaysOnVpnStatus(this) + val store = ConfigStore(this) + val profileId = if (status.enabled) store.alwaysOnProfileId() else store.connectionProfile().id + VpnRuntimeState.updateSystem(status.enabled, status.lockdown, profileId) + } +} + +@Composable +private fun ProfileTypeBadge(type: ProxyType, foreground: Color) { + Surface( + color = foreground.copy(alpha = 0.14f), + contentColor = foreground, + shape = RoundedCornerShape(50), + border = BorderStroke(1.dp, foreground.copy(alpha = 0.5f)), + ) { + Text( + when (type) { + ProxyType.HTTPS -> "HTTPS" + ProxyType.SSH -> "SSH" + ProxyType.SSH_JUMP -> "SSH + Jump" + }, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainScreen(activity: Activity) { + val connection by VpnRuntimeState.connection + val runtimeAlwaysOn by VpnRuntimeState.alwaysOn + val runtimeLockdown by VpnRuntimeState.lockdown + val runtimeProfileId by VpnRuntimeState.connectionProfileId + val networkWarning by VpnRuntimeState.networkWarning + val transportProtocol by VpnRuntimeState.transportProtocol + val pendingHostKey by SshHostKeyPromptState.pending + val store = remember { ConfigStore(activity) } + var error by remember { mutableStateOf(null) } + var profileMenuExpanded by remember { mutableStateOf(false) } + var profiles by remember { mutableStateOf(store.sortedProfiles()) } + var activeProfileId by remember { mutableStateOf(store.activeProfileId()) } + var connectionProfileId by remember { mutableStateOf(store.connectionProfile().id) } + var connectionStats by remember { mutableStateOf(null) } + var systemVpnStatus by remember { mutableStateOf(readAlwaysOnVpnStatus(activity)) } + var vpnPermissionRequestedAt by remember { mutableStateOf(0L) } + var showCrashReport by remember { mutableStateOf(CrashHandler.hasPendingReport()) } + var showAlwaysOnConflict by remember { mutableStateOf(false) } + var pendingReconnect by remember { mutableStateOf(store.hasPendingReconnect()) } + var globalSettings by remember { mutableStateOf(store.globalConnectionSettings()) } + val alwaysOn = runtimeAlwaysOn || systemVpnStatus.enabled + val lockdown = runtimeLockdown || systemVpnStatus.lockdown + val lifecycleOwner = LocalLifecycleOwner.current + val scope = rememberCoroutineScope() + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + scope.launch { + val refreshed = withContext(ConfigIoDispatcher) { + val status = readAlwaysOnVpnStatus(activity) + RefreshedMainConfig( + status = status, + profiles = store.sortedProfiles(), + activeProfileId = store.activeProfileId(), + connectionProfileId = if (status.enabled) store.alwaysOnProfileId() else store.connectionProfile().id, + pendingReconnect = store.hasPendingReconnect(), + globalSettings = store.globalConnectionSettings(), + ) + } + systemVpnStatus = refreshed.status + profiles = refreshed.profiles + activeProfileId = refreshed.activeProfileId + connectionProfileId = refreshed.connectionProfileId + pendingReconnect = refreshed.pendingReconnect + globalSettings = refreshed.globalSettings + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + LaunchedEffect(connection) { + pendingReconnect = store.hasPendingReconnect() + } + LaunchedEffect(lifecycleOwner, connection) { + if (connection != VpnConnectionState.CONNECTED) { + connectionStats = null + return@LaunchedEffect + } + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + var previous: NativeConnectionStats? = null + var smoothedDownload = 0.0 + var smoothedUpload = 0.0 + while (true) { + // JNI reflection and JSON decoding are small but not frame work. Some + // vendor devices expose their cost as visible input latency, so sample + // away from the main dispatcher. + val snapshot = withContext(Dispatchers.Default) { + ConnectionStatsReader.snapshot() + } + if (snapshot != null) { + previous?.let { old -> + val download = (snapshot.downloadBytes - old.downloadBytes).coerceAtLeast(0).toDouble() + val upload = (snapshot.uploadBytes - old.uploadBytes).coerceAtLeast(0).toDouble() + val alpha = 0.35 + smoothedDownload = if (smoothedDownload == 0.0) download else alpha * download + (1 - alpha) * smoothedDownload + smoothedUpload = if (smoothedUpload == 0.0) upload else alpha * upload + (1 - alpha) * smoothedUpload + } + previous = snapshot + connectionStats = DisplayedConnectionStats(snapshot, smoothedDownload, smoothedUpload) + } + delay(1_000) + } + } + } + val vpnPermission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (VpnService.prepare(activity) == null) { + if (!isAlwaysOnVpnActive(activity)) ProxyVpnService.start(activity) else error = null + } else { + val status = readAlwaysOnVpnStatus(activity) + val dismissedImmediately = System.currentTimeMillis() - vpnPermissionRequestedAt < 1_000 + if (status.hasOtherProvider || dismissedImmediately) { + error = null + showAlwaysOnConflict = true + } else { + error = "VPN access was not granted in the Android confirmation dialog" + } + } + } + val requestVpnAccess = { + val status = readAlwaysOnVpnStatus(activity) + if (status.hasOtherProvider) { + error = null + showAlwaysOnConflict = true + } else { + val intent = VpnService.prepare(activity) + if (intent == null) { + ProxyVpnService.start(activity) + } else { + vpnPermissionRequestedAt = System.currentTimeMillis() + vpnPermission.launch(intent) + } + } + } + val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + requestVpnAccess() + } + val connect = { + if (isAlwaysOnVpnActive(activity)) { + systemVpnStatus = readAlwaysOnVpnStatus(activity) + error = null + } else { + error = globalSettings.applyTo(store.activeProfile().config).validationError() + } + if (error == null && !isAlwaysOnVpnActive(activity)) { + if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission( + activity, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + requestVpnAccess() + } + } + } + + Scaffold( + topBar = { TopAppBar(title = { Text("MegaProxy") }) }, + contentWindowInsets = WindowInsets.safeDrawing, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) { + Column( + Modifier.widthIn(max = 720.dp).fillMaxWidth().fillMaxHeight().verticalScroll(rememberScrollState()).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val connected = connection == VpnConnectionState.CONNECTED + val statusLabel = when (connection) { + VpnConnectionState.CONNECTED -> "Connected" + VpnConnectionState.CONNECTING -> "Connecting" + VpnConnectionState.DISCONNECTED -> "Disconnected" + } + val statusColor = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Card(Modifier.fillMaxWidth().semantics { + liveRegion = LiveRegionMode.Polite + stateDescription = statusLabel + }) { + Column( + Modifier.fillMaxWidth().padding(vertical = 28.dp, horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + if (connected) Icons.Filled.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = null, + tint = statusColor, + ) + Text( + if (connection == VpnConnectionState.CONNECTING) "Connecting…" else statusLabel, + style = MaterialTheme.typography.headlineSmall, + color = statusColor, + ) + if (alwaysOn) { + Text( + if (lockdown) "Always-on · Block without VPN" else "Always-on VPN", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (connected && transportProtocol != VpnTransportProtocol.UNKNOWN) { + val transportLabel = when (transportProtocol) { + VpnTransportProtocol.HTTP_1_1 -> stringResource(R.string.transport_http_1_1) + VpnTransportProtocol.HTTP_2 -> stringResource(R.string.transport_http_2) + VpnTransportProtocol.SSH_MULTIPLEXED -> stringResource(R.string.transport_ssh_multiplexed) + VpnTransportProtocol.UNKNOWN -> "" + } + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = RoundedCornerShape(999.dp), + ) { + Text( + transportLabel, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + } + } + Spacer(Modifier.height(4.dp)) + networkWarning?.let { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), modifier = Modifier.fillMaxWidth()) { + Text(it, color = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.padding(14.dp)) + } + Spacer(Modifier.height(12.dp)) + } + connectionStats?.let { stats -> + ConnectionStatsCard(stats) + Spacer(Modifier.height(12.dp)) + } + val displayedProfileId = if (alwaysOn) runtimeProfileId.ifEmpty { connectionProfileId } else activeProfileId + val activeProfile = profiles.firstOrNull { it.id == displayedProfileId } ?: profiles.first() + val actualProfile = profiles.firstOrNull { it.id == runtimeProfileId } + val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError() + val profileColor = Color(ProfileColors.argb[Math.floorMod(activeProfile.colorIndex, ProfileColors.argb.size)]) + val onProfileColor = if (profileColor.luminance() > 0.45f) Color.Black else Color.White + Box(Modifier.fillMaxWidth()) { + Card( + Modifier.fillMaxWidth().clickable { profileMenuExpanded = true }, + colors = CardDefaults.cardColors( + containerColor = profileColor, + contentColor = onProfileColor, + disabledContainerColor = profileColor, + disabledContentColor = onProfileColor, + ), + ) { + Column(Modifier.fillMaxWidth().padding(14.dp)) { + Text("Profile", style = MaterialTheme.typography.labelMedium) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (activeProfile.flagEmoji.isNotEmpty()) { + Surface( + color = Color.White, + contentColor = Color.Black, + shape = RoundedCornerShape(6.dp), + border = BorderStroke(1.dp, Color.Black.copy(alpha = 0.45f)), + ) { + Text(activeProfile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)) + } + } + Text(activeProfile.displayName, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + ProfileTypeBadge(activeProfile.config.type, onProfileColor) + Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select profile", tint = onProfileColor) + } + if (actualProfile != null && actualProfile.id != activeProfile.id && connection != VpnConnectionState.DISCONNECTED) { + Text("Connected through: ${actualProfile.displayNameWithFlag}", style = MaterialTheme.typography.bodySmall) + } + DropdownMenu(profileMenuExpanded, { profileMenuExpanded = false }) { + profiles.forEach { profile -> + DropdownMenuItem( + text = { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(profile.displayNameWithFlag, modifier = Modifier.weight(1f)) + ProfileTypeBadge(profile.config.type, MaterialTheme.colorScheme.onSurface) + } + }, + onClick = { + val useAsAlwaysOn = isAlwaysOnVpnActive(activity) + if (useAsAlwaysOn) { + connectionProfileId = profile.id + } else { + activeProfileId = profile.id + } + if (useAsAlwaysOn || connection != VpnConnectionState.DISCONNECTED) { + ProxyVpnService.switchProfile(activity, profile.id, useAsAlwaysOn) + } else { + scope.launch(ConfigIoDispatcher) { store.setActiveProfile(profile.id) } + } + profileMenuExpanded = false + }, + ) + } + } + } + } + } + if (connection == VpnConnectionState.DISCONNECTED && activeProfileError != null) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(activeProfileError, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onErrorContainer) + TextButton(onClick = { + activity.startActivity(Intent(activity, ProfileEditorActivity::class.java).apply { + putExtra(ProfileEditorActivity.EXTRA_PROFILE_ID, activeProfile.id) + }) + }) { Text(stringResource(R.string.configure)) } + } + } + } + Button( + onClick = { + if (isAlwaysOnVpnActive(activity)) { + systemVpnStatus = readAlwaysOnVpnStatus(activity) + } else if (connection != VpnConnectionState.DISCONNECTED) { + ProxyVpnService.stop(activity) + } else { + connect() + } + }, + enabled = !alwaysOn && + (connection != VpnConnectionState.DISCONNECTED || activeProfileError == null), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + when (connection) { + VpnConnectionState.CONNECTED -> stringResource(R.string.disconnect) + VpnConnectionState.CONNECTING -> stringResource(R.string.disconnect) + VpnConnectionState.DISCONNECTED -> stringResource(R.string.connect) + }, + ) + } + if (connected) { + FilledTonalButton( + onClick = { ProxyVpnService.reconnect(activity) }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringResource(R.string.reconnect)) + if (pendingReconnect) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + shape = RoundedCornerShape(999.dp), + ) { + Text( + stringResource(R.string.apply_new_settings), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + } + } + } + FilledTonalButton( + onClick = { activity.startActivity(Intent(activity, ConnectionTestActivity::class.java)) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.test_connection)) } + FilledTonalButton( + onClick = { activity.startActivity(Intent(activity, ProxySettingsActivity::class.java)) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.settings)) } + FilledTonalButton( + onClick = { + scope.launch { + runCatching { + val intent = withContext(Dispatchers.IO) { + FeedbackEmail.createIntent(activity, connection, alwaysOn, lockdown) + } + activity.startActivity(intent) + }.onFailure { + error = "No email client is available: ${it.message ?: "unknown error"}" + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.feedback)) } + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 12.dp)) + } + if (alwaysOn) { + Text( + "Always-on VPN is used. Selecting a profile updates the Always-on profile and reconnects immediately; disconnect remains managed by Android.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } + } + } + } + + if (showCrashReport) { + AlertDialog( + onDismissRequest = {}, + title = { Text("MegaProxy stopped unexpectedly") }, + text = { Text("A privacy-filtered crash report was saved. You can send it to help diagnose the problem.") }, + confirmButton = { + TextButton(onClick = { + scope.launch { + runCatching { + val intent = withContext(Dispatchers.IO) { + FeedbackEmail.createIntent( + activity, + connection, + alwaysOn, + lockdown, + crashReport = true, + ) + } + activity.startActivity(intent) + CrashHandler.markReportHandled() + showCrashReport = false + }.onFailure { + error = "Could not open an email client: ${it.message ?: "unknown error"}" + } + } + }) { Text("Report") } + }, + dismissButton = { + TextButton(onClick = { + CrashHandler.markReportHandled() + showCrashReport = false + }) { Text("Close") } + }, + ) + } + + if (showAlwaysOnConflict) { + AlertDialog( + onDismissRequest = { showAlwaysOnConflict = false }, + title = { Text("Always-on VPN is already in use") }, + text = { Text(OTHER_ALWAYS_ON_VPN_MESSAGE) }, + confirmButton = { + TextButton(onClick = { + showAlwaysOnConflict = false + openAndroidVpnSettings(activity) + }) { Text("Change settings") } + }, + dismissButton = { + TextButton(onClick = { showAlwaysOnConflict = false }) { Text("Cancel") } + }, + ) + } + + pendingHostKey?.takeIf { !it.testOnly }?.let { pending -> + fun dismissHostKeyPrompt() { + SshHostKeyPromptState.clear() + ProxyVpnService.dismissHostKeyPrompt(activity) + } + AlertDialog( + onDismissRequest = ::dismissHostKeyPrompt, + title = { Text(if (pending.changed) "SSH host key changed" else "Trust SSH host key?") }, + text = { Text(buildString { + if (pending.changed) { + append("Warning: the previously trusted key for the ${pending.hop} host has changed. This may indicate a server reinstall or a man-in-the-middle attack. Verify it through a trusted channel before replacing it.\n\n") + } else { + append("This is the first connection to the ${pending.hop} SSH host. Verify its fingerprint through a trusted channel.\n\n") + } + append("Algorithm: ${pending.algorithm}\nFingerprint: ${pending.fingerprint}") + }) }, + confirmButton = { + TextButton(onClick = { + if (store.trustSshHostKey(pending.profileId, pending.hop, pending.fingerprint)) { + SshHostKeyPromptState.clear() + ProxyVpnService.reconnect(activity) + } else { + error = "The SSH host key could not be saved to this profile" + } + }) { Text(if (pending.changed) "Replace trusted key" else "Trust and connect") } + }, + dismissButton = { + TextButton(onClick = ::dismissHostKeyPrompt) { Text("Cancel") } + }, + ) + } +} + +private fun isAlwaysOnVpnActive(activity: Activity): Boolean = + ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled + +private data class DisplayedConnectionStats( + val native: NativeConnectionStats, + val downloadBytesPerSecond: Double, + val uploadBytesPerSecond: Double, +) + +private data class RefreshedMainConfig( + val status: AlwaysOnVpnStatus, + val profiles: List, + val activeProfileId: String, + val connectionProfileId: String, + val pendingReconnect: Boolean, + val globalSettings: net.megaproxy487.model.GlobalConnectionSettings, +) + +@Composable +private fun ConnectionStatsCard(stats: DisplayedConnectionStats) { + Card(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + StatValue("Download", "↓ ${formatRate(stats.downloadBytesPerSecond)}") + StatValue("Upload", "↑ ${formatRate(stats.uploadBytesPerSecond)}") + val latency = stats.native.proxyLatencyMillis + val ageMillis = System.currentTimeMillis() - stats.native.proxyLatencyAtMillis + StatValue( + "Proxy latency", + if (latency <= 0) "—" else "${latency.toInt()} ms${formatAge(ageMillis)}", + ) + } + Text( + if (stats.native.connectionSamples == 0) "Connection errors: no samples" + else "Connection errors: ${"%.1f".format(stats.native.connectionErrorRate * 100)}% · ${stats.native.connectionSamples} samples", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 12.dp), + ) + } +} + +@Composable +private fun StatValue(label: String, value: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(value, style = MaterialTheme.typography.titleSmall) + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +private fun formatRate(bytesPerSecond: Double): String = when { + bytesPerSecond >= 1_000_000 -> "%.1f MB/s".format(bytesPerSecond / 1_000_000) + bytesPerSecond >= 1_000 -> "%.1f KB/s".format(bytesPerSecond / 1_000) + else -> "${bytesPerSecond.toInt()} B/s" +} + +private fun formatAge(ageMillis: Long): String = when { + ageMillis < 30_000 -> "" + ageMillis < 120_000 -> " · ${ageMillis / 1_000}s ago" + else -> " · ${ageMillis / 60_000}m ago" +} diff --git a/app/src/main/java/net/megaproxy487/MegaProxyApplication.kt b/app/src/main/java/net/megaproxy487/MegaProxyApplication.kt new file mode 100644 index 0000000..77666fc --- /dev/null +++ b/app/src/main/java/net/megaproxy487/MegaProxyApplication.kt @@ -0,0 +1,19 @@ +package net.megaproxy487 + +import android.app.Application +import android.content.Context +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.vpn.PersistentDiagnosticLog + +class MegaProxyApplication : Application() { + override fun attachBaseContext(base: Context) { + super.attachBaseContext(AppLanguageManager.wrap(base)) + } + + override fun onCreate() { + super.onCreate() + val store = ConfigStore(this) + PersistentDiagnosticLog.initialize(this, store.diagnosticLogLimitMb()) + CrashHandler.install(this) + } +} diff --git a/app/src/main/java/net/megaproxy487/PackageReplacedReceiver.kt b/app/src/main/java/net/megaproxy487/PackageReplacedReceiver.kt new file mode 100644 index 0000000..65383c6 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/PackageReplacedReceiver.kt @@ -0,0 +1,42 @@ +package net.megaproxy487 + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.VpnService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.vpn.DiagnosticLog +import net.megaproxy487.vpn.ProxyVpnService + +/** Restores a user-requested VPN connection after this package is replaced. */ +class PackageReplacedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return + val pendingResult = goAsync() + val app = context.applicationContext + CoroutineScope(SupervisorJob() + ConfigIoDispatcher).launch { + try { + if (!ConfigStore(app).isConnectionDesired()) { + DiagnosticLog.add("event=package_replaced reconnect=skipped reason=connection_not_desired") + return@launch + } + if (VpnService.prepare(app) != null) { + // Android may revoke VPN consent independently of an app update. A + // background receiver cannot display the confirmation dialog. + DiagnosticLog.add("event=package_replaced reconnect=blocked reason=vpn_permission_required") + return@launch + } + DiagnosticLog.add("event=package_replaced reconnect=requested") + ProxyVpnService.restoreAfterPackageUpdate(app) + } catch (error: Exception) { + DiagnosticLog.add("event=package_replaced reconnect=failed reason=${error.javaClass.simpleName}") + } finally { + pendingResult.finish() + } + } + } +} diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt b/app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt new file mode 100644 index 0000000..6ffcd8b --- /dev/null +++ b/app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt @@ -0,0 +1,448 @@ +package net.megaproxy487 + +import android.app.Activity +import android.net.Uri +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.data.readPrivateKeyText +import net.megaproxy487.model.DnsProvider +import net.megaproxy487.model.ProfileColorMatcher +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyType +import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.vpn.readAlwaysOnVpnStatus +import net.megaproxy487.ui.theme.MegaProxyTheme +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ProfileEditorActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + setContent { MegaProxyTheme { ProfileEditorScreen(this) } } + } + + companion object { + const val EXTRA_PROFILE_ID = "profile_id" + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProfileEditorScreen(activity: Activity) { + val store = remember { ConfigStore(activity) } + val coroutineScope = rememberCoroutineScope() + val profileId = remember { activity.intent.getStringExtra(ProfileEditorActivity.EXTRA_PROFILE_ID) } + var profile by remember { mutableStateOf(store.profile(profileId.orEmpty()) ?: store.activeProfile()) } + var config by remember { mutableStateOf(profile.config) } + var portText by remember { mutableStateOf(config.port.toString()) } + var jumpPortText by remember { mutableStateOf(config.jumpPort.toString()) } + var error by remember { mutableStateOf(null) } + var countryExpanded by remember { mutableStateOf(false) } + var dnsExpanded by remember { mutableStateOf(false) } + var showInvalidCertificateWarning by remember { mutableStateOf(false) } + var typeExpanded by remember { mutableStateOf(false) } + var unsafeHostKeyHop by remember { mutableStateOf(null) } + var showReconnectPrompt by remember { mutableStateOf(false) } + var showAlwaysOnNotice by remember { mutableStateOf(false) } + var connectionChangeDeferred by remember { mutableStateOf(false) } + val countries = remember { + Locale.getISOCountries().map { code -> + code to Locale.Builder().setRegion(code).build().getDisplayCountry(Locale.getDefault()) + }.sortedBy { it.second.lowercase(Locale.getDefault()) } + } + + val globalSettings = remember { store.globalConnectionSettings() } + val editedConnectionProfileId = remember { store.connectionProfile().id } + val alwaysOnActive = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled } + fun saveProfile() { + val snapshot = profile + coroutineScope.launch(ConfigIoDispatcher) { store.saveProfile(snapshot) } + } + fun acceptText(value: String, maxLength: Int, update: (String) -> Unit) { + if (value.length <= maxLength) update(value) + } + fun updateConfig(updated: ProxyConfig) { + config = updated + profile = profile.copy(config = updated) + saveProfile() + error = globalSettings.applyTo(updated).connectionValidationError() + if (ProxyVpnService.isRunning && profile.id == editedConnectionProfileId) { + coroutineScope.launch(ConfigIoDispatcher) { store.markPendingReconnect() } + } + if (ProxyVpnService.isRunning && !connectionChangeDeferred && profile.id == editedConnectionProfileId) { + if (alwaysOnActive) { + connectionChangeDeferred = true + showAlwaysOnNotice = true + } else { + showReconnectPrompt = true + } + } + } + fun importPrivateKey(uri: Uri, jump: Boolean) { + coroutineScope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + activity.contentResolver.openInputStream(uri)?.use { it.readPrivateKeyText() } + ?: throw IllegalArgumentException("Unable to open the selected file") + } + } + result.onSuccess { imported -> + updateConfig(if (jump) config.copy(jumpPrivateKey = imported) else config.copy(privateKey = imported)) + error = null + }.onFailure { failure -> + error = failure.message ?: "Unable to import the private key" + } + } + } + val destinationKeyPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { importPrivateKey(it, jump = false) } + } + val jumpKeyPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { importPrivateKey(it, jump = true) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(profile.displayName) }, + navigationIcon = { + IconButton(onClick = { activity.finish() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), + ) { padding -> + LazyColumn( + Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + OutlinedTextField(profile.name, { name -> acceptText(name, 256) { + profile = profile.copy(name = name) + saveProfile() + } }, label = { Text("Profile name (optional)") }, supportingText = { Text("Defaults to the proxy host") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + ExposedDropdownMenuBox(countryExpanded, { countryExpanded = it }) { + OutlinedTextField( + profile.countryCode.takeIf(String::isNotEmpty)?.let { code -> + "${profile.flagEmoji} ${countries.firstOrNull { it.first == code }?.second ?: code}" + } ?: "No flag", + {}, readOnly = true, label = { Text("Country flag") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(countryExpanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + ) + DropdownMenu(countryExpanded, { countryExpanded = false }) { + DropdownMenuItem(text = { Text("No flag") }, onClick = { + profile = profile.copy(countryCode = "") + saveProfile() + countryExpanded = false + }) + countries.forEach { (code, name) -> + val flag = net.megaproxy487.model.ProxyProfile("", colorIndex = 0, countryCode = code).flagEmoji + DropdownMenuItem(text = { Text("$flag $name") }, onClick = { + profile = profile.copy( + countryCode = code, + colorIndex = ProfileColorMatcher.colorIndexForFlag(code, profile.colorIndex), + ) + saveProfile() + countryExpanded = false + }) + } + } + } + } + + item { + Text("Connection", style = MaterialTheme.typography.titleMedium) + } + item { + ExposedDropdownMenuBox(typeExpanded, { typeExpanded = it }) { + OutlinedTextField(config.type.title, {}, readOnly = true, label = { Text("Profile type") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + DropdownMenu(typeExpanded, { typeExpanded = false }) { + ProxyType.entries.forEach { type -> DropdownMenuItem(text = { Text(type.title) }, onClick = { + updateConfig(config.copy(type = type, port = type.defaultPort)) + portText = type.defaultPort.toString() + if (type == ProxyType.SSH_JUMP) jumpPortText = config.jumpPort.toString() + typeExpanded = false + }) } + } + } + } + item { + OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "HTTPS proxy hostname" else "Destination SSH hostname") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(portText, { value -> + portText = value + val port = value.toIntOrNull() + if (port == null) error = "Port must be between 1 and 65535" + else updateConfig(config.copy(port = port)) + }, label = { Text("Port") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "Basic Auth username" else "SSH username") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "Password" else "SSH password (optional)") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + } + if (config.type == ProxyType.HTTPS) item { SettingCheckboxRow( + checked = config.allowInvalidProxyCertificate, + title = "Allow self-signed proxy certificate", + description = "Disables certificate verification only for the HTTPS proxy.", + onCheckedChange = { checked -> + if (checked) showInvalidCertificateWarning = true + else updateConfig(config.copy(allowInvalidProxyCertificate = false)) + }, + ) + } + + if (config.type != ProxyType.HTTPS) { + item { + OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it)) } }, label = { Text("Private key (optional)") }, supportingText = { Text("PEM or OpenSSH format. Passphrase-protected keys are not supported.") }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedButton( + onClick = { destinationKeyPicker.launch(arrayOf("*/*")) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.import_destination_key)) } + } + if (config.password.isBlank() && config.privateKey.isBlank()) { + item { + Text( + "No SSH password or private key is configured. Connection will only work if the server permits authentication without credentials.", + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) + } + } + item { + SettingCheckboxRow(config.acceptAnyHostKey, "Accept any destination host key", "Unsafe: disables SSH server identity verification.") { checked -> + if (checked) unsafeHostKeyHop = "destination" else updateConfig(config.copy(acceptAnyHostKey = false)) + } + } + if (config.trustedHostKey.isNotBlank()) item { Text("Trusted destination key: ${config.trustedHostKey}", style = MaterialTheme.typography.bodySmall) } + } + + if (config.type == ProxyType.SSH_JUMP) { + item { + HorizontalDivider() + } + item { + Text("Jump host", style = MaterialTheme.typography.titleMedium) + } + item { + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { Text("Jump SSH hostname") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(jumpPortText, { value -> + if (value.length <= 5 && value.all(Char::isDigit)) { + jumpPortText = value + value.toIntOrNull()?.let { updateConfig(config.copy(jumpPort = it)) } + } + }, label = { Text("Jump port") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + SettingCheckboxRow(config.sameJumpAuthentication, "Use the same authentication", "Reuse destination username, password and private key.") { + updateConfig(config.copy(sameJumpAuthentication = it)) + } + } + if (!config.sameJumpAuthentication) { + item { + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { Text("Jump SSH username") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { Text("Jump SSH password (optional)") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it)) } }, label = { Text("Jump private key (optional)") }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedButton( + onClick = { jumpKeyPicker.launch(arrayOf("*/*")) }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.import_jump_key)) } + } + if (config.jumpPassword.isBlank() && config.jumpPrivateKey.isBlank()) { + item { + Text( + "No jump password or private key is configured. Connection will only work if the jump server permits authentication without credentials.", + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + item { + SettingCheckboxRow(config.jumpAcceptAnyHostKey, "Accept any jump host key", "Unsafe: disables jump host identity verification.") { checked -> + if (checked) unsafeHostKeyHop = "jump" else updateConfig(config.copy(jumpAcceptAnyHostKey = false)) + } + } + if (config.jumpTrustedHostKey.isNotBlank()) item { Text("Trusted jump key: ${config.jumpTrustedHostKey}", style = MaterialTheme.typography.bodySmall) } + } + + item { + SettingCheckboxRow(config.allowIpv6, "Enable IPv6 destinations", "Enable only if this proxy can reach IPv6 destinations.") { + updateConfig(config.copy(allowIpv6 = it)) + } + } + + item { + Text("DNS over HTTPS", style = MaterialTheme.typography.titleMedium) + } + item { + ExposedDropdownMenuBox(dnsExpanded, { dnsExpanded = it }) { + OutlinedTextField(config.dnsProvider.title, {}, readOnly = true, label = { Text("DNS over HTTPS") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + DropdownMenu(dnsExpanded, { dnsExpanded = false }) { + DnsProvider.entries.forEach { provider -> DropdownMenuItem(text = { Text(provider.title) }, onClick = { updateConfig(config.copy(dnsProvider = provider)); dnsExpanded = false }) } + } + } + } + if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { Text("Custom DoH URL") }, singleLine = true, modifier = Modifier.fillMaxWidth()) } + error?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } } + if (showReconnectPrompt) { + item { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text("Reconnect to apply these changes to the active VPN.", style = MaterialTheme.typography.bodyMedium) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { + showReconnectPrompt = false + connectionChangeDeferred = true + }) { Text("Next connection") } + TextButton(onClick = { + showReconnectPrompt = false + connectionChangeDeferred = true + ProxyVpnService.reconnect(activity) + }) { Text("Reconnect now") } + } + } + } + } + } + if (showAlwaysOnNotice) { + item { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { + Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text("Always-on is active. Changes apply on the next connection.", modifier = Modifier.weight(1f)) + TextButton(onClick = { showAlwaysOnNotice = false }) { Text("Dismiss") } + } + } + } + } + item { + Text("Changes are saved automatically.", style = MaterialTheme.typography.bodySmall) + } + } + } + + if (showInvalidCertificateWarning) { + AlertDialog( + onDismissRequest = { showInvalidCertificateWarning = false }, + title = { Text("Allow an untrusted proxy certificate?") }, + text = { Text("This disables certificate-chain and hostname verification between your phone and the HTTPS proxy. An attacker could impersonate the proxy and obtain its Basic Auth credentials. Destination HTTPS certificates remain verified.") }, + confirmButton = { TextButton(onClick = { + updateConfig(config.copy(allowInvalidProxyCertificate = true)) + showInvalidCertificateWarning = false + }) { Text("OK") } }, + dismissButton = { TextButton(onClick = { showInvalidCertificateWarning = false }) { Text("Cancel") } }, + ) + } + unsafeHostKeyHop?.let { hop -> + AlertDialog( + onDismissRequest = { unsafeHostKeyHop = null }, + title = { Text("Accept any SSH host key?") }, + text = { Text("This disables identity verification for the $hop SSH host and permits man-in-the-middle attacks. Credentials and tunneled traffic could be intercepted.") }, + confirmButton = { TextButton(onClick = { updateConfig(if (hop == "jump") config.copy(jumpAcceptAnyHostKey = true) else config.copy(acceptAnyHostKey = true)); unsafeHostKeyHop = null }) { Text("Accept any key") } }, + dismissButton = { TextButton(onClick = { unsafeHostKeyHop = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun SettingCheckboxRow( + checked: Boolean, + title: String, + description: String, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = checked, + onValueChange = onCheckedChange, + role = Role.Checkbox, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title) + Text(description, style = MaterialTheme.typography.bodySmall) + } + Checkbox(checked, null) + } +} diff --git a/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt b/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt new file mode 100644 index 0000000..46da028 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt @@ -0,0 +1,525 @@ +package net.megaproxy487 + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.zIndex +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.data.ConfigExportFormat +import net.megaproxy487.data.ConfigTransfer +import net.megaproxy487.data.FoxyProxyParser +import net.megaproxy487.data.PortableConfiguration +import net.megaproxy487.data.ProxyListParser +import net.megaproxy487.data.SuperProxyParser +import net.megaproxy487.data.readConfigText +import net.megaproxy487.model.ProfileColors +import net.megaproxy487.model.ProxyProfile +import net.megaproxy487.model.ProxyType +import net.megaproxy487.vpn.PersistentDiagnosticLog +import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.ui.theme.MegaProxyTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ProfilesActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MegaProxyTheme { SettingsScreen(this) } } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsScreen(activity: Activity) { + val store = remember { ConfigStore(activity) } + val scope = rememberCoroutineScope() + val profiles = remember { mutableStateListOf() } + var deleteProfile by remember { mutableStateOf(null) } + var importedProfileCount by remember { mutableStateOf(0) } + var importError by remember { mutableStateOf(null) } + var skippedNonHttps by remember { mutableStateOf(0) } + var showImportFilterNotice by remember { mutableStateOf(false) } + var showExportDialog by remember { mutableStateOf(false) } + var showPasswordExportWarning by remember { mutableStateOf(false) } + var exportFormat by remember { mutableStateOf(ConfigExportFormat.JSON) } + var includePasswords by remember { mutableStateOf(false) } + var includePrivateKeys by remember { mutableStateOf(false) } + var pendingExportContent by remember { mutableStateOf("") } + var transferMessage by remember { mutableStateOf(null) } + var pendingUnsafeImport by remember { mutableStateOf(null) } + val lifecycleOwner = LocalLifecycleOwner.current + val listState = rememberLazyListState() + var draggedProfileId by remember { mutableStateOf(null) } + + fun refresh() { + scope.launch { + val loaded = withContext(ConfigIoDispatcher) { store.sortedProfiles() } + profiles.clear() + profiles.addAll(loaded) + } + } + LaunchedEffect(Unit) { refresh() } + fun moveProfile(profileId: String, delta: Int): Boolean { + val sourceIndex = profiles.indexOfFirst { it.id == profileId } + val targetIndex = sourceIndex + delta + if (sourceIndex < 0 || targetIndex !in profiles.indices) return false + profiles.add(targetIndex, profiles.removeAt(sourceIndex)) + val order = profiles.map(ProxyProfile::id) + scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) } + return true + } + fun edit(profile: ProxyProfile) { + activity.startActivity(Intent(activity, ProfileEditorActivity::class.java).apply { + putExtra(ProfileEditorActivity.EXTRA_PROFILE_ID, profile.id) + }) + } + fun writeExport(uri: Uri?) { + if (uri == null) return + val content = pendingExportContent + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + activity.contentResolver.openOutputStream(uri, "wt")?.bufferedWriter()?.use { it.write(content) } + ?: error("Could not open the export file") + } + } + result.onSuccess { transferMessage = "Configuration exported successfully." } + .onFailure { importError = it.message ?: "Could not export the configuration" } + } + } + fun applyJsonImport(configuration: PortableConfiguration) { + scope.launch { + val added = withContext(ConfigIoDispatcher) { + store.importConfiguration(configuration).also { + if (ProxyVpnService.isRunning) store.markPendingReconnect() + PersistentDiagnosticLog.setLimitMb(store.diagnosticLogLimitMb()) + } + } + refresh() + val missingPasswords = added.count { it.config.password.isEmpty() } + transferMessage = buildString { + append("Imported ${added.size} profile(s) from JSON.") + if (configuration.skippedProfiles > 0) append(" Skipped ${configuration.skippedProfiles} invalid profile(s).") + if (missingPasswords > 0) append(" $missingPasswords profile(s) require a password.") + if (ProxyVpnService.isAlwaysOnMode && ProxyVpnService.isRunning) append(" Reconnect the VPN to apply the imported Always-on profile selection.") + } + importedProfileCount = 0 + } + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) refresh() } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val exportTxtDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain"), ::writeExport) + val exportJsonDocument = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json"), ::writeExport) + fun launchExport() { + scope.launch { + pendingExportContent = withContext(ConfigIoDispatcher) { + when (exportFormat) { + ConfigExportFormat.PROXY_LIST -> ConfigTransfer.exportProxyList(store.profiles(), includePasswords) + ConfigExportFormat.JSON -> ConfigTransfer.exportJson(store, includePasswords, includePrivateKeys) + } + } + if (exportFormat == ConfigExportFormat.PROXY_LIST) exportTxtDocument.launch("ProxyList.txt") + else exportJsonDocument.launch("MegaProxy-config.json") + } + } + val importDocument = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + runCatching { + val text = activity.contentResolver.openInputStream(uri)?.buffered()?.use { it.readConfigText() } + ?: error("Could not read the selected file") + val isJson = activity.contentResolver.getType(uri) == "application/json" || + uri.lastPathSegment.orEmpty().substringAfterLast('.', "").equals("json", true) || + text.trimStart().startsWith('{') + if (isJson) { + val isMegaProxy = runCatching { + org.json.JSONObject(text).optString("schema") == "dev.megaproxy.config" + }.getOrDefault(false) + if (isMegaProxy) { + val configuration = ConfigTransfer.importJson(text) + if (configuration.profiles.any { it.config.allowInvalidProxyCertificate || it.config.acceptAnyHostKey || it.config.jumpAcceptAnyHostKey }) { + pendingUnsafeImport = configuration + } else { + applyJsonImport(configuration) + } + } else { + val imported = FoxyProxyParser.parse(text).getOrThrow() + val added = store.importProfiles(imported.proxies) + refresh() + importedProfileCount = added.size + skippedNonHttps = imported.skippedNonHttps + showImportFilterNotice = imported.skippedNonHttps > 0 + if (imported.skippedNonHttps == 0) { + transferMessage = "Imported ${added.size} HTTPS proxy profile(s) from FoxyProxy." + } + } + } else { + val isSuperProxy = SuperProxyParser.matches(text) + val imported = if (isSuperProxy) { + SuperProxyParser.parse(text).getOrThrow() + } else { + ProxyListParser.parse(text).getOrThrow() + } + val added = store.importProfiles(imported.proxies) + refresh() + importedProfileCount = added.size + skippedNonHttps = imported.skippedNonHttps + showImportFilterNotice = imported.skippedNonHttps > 0 + if (imported.skippedNonHttps == 0) { + transferMessage = if (isSuperProxy) { + "Imported ${added.size} HTTPS proxy profile(s) from Super Proxy. Certificate pins were not imported." + } else { + "Imported ${added.size} HTTPS proxy profile(s)." + } + } + } + importError = null + }.onFailure { importError = it.message ?: "Could not import the configuration" } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.profiles)) }, + navigationIcon = { + IconButton(onClick = { activity.finish() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton(onClick = { importDocument.launch(arrayOf("text/plain", "application/json", "application/octet-stream")) }) { + Text(stringResource(R.string.import_action)) + } + TextButton(onClick = { showExportDialog = true }) { Text(stringResource(R.string.export_action)) } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { + scope.launch { + val profile = withContext(ConfigIoDispatcher) { store.addProfile() } + refresh() + edit(profile) + } + }) { + Icon(Icons.Default.Add, contentDescription = "Add profile") + } + }, + contentWindowInsets = WindowInsets.safeDrawing, + ) { padding -> + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.TopCenter, + ) { + LazyColumn( + Modifier.fillMaxHeight().fillMaxWidth().widthIn(max = 840.dp).padding(horizontal = 16.dp), + state = listState, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { + Text( + "Long press and drag a profile to reorder it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + items(profiles, key = { it.id }) { profile -> + var dragOffset by remember(profile.id) { mutableStateOf(0f) } + ProfileCard( + profile = profile, + modifier = Modifier + .zIndex(if (draggedProfileId == profile.id) 1f else 0f) + .graphicsLayer { + translationY = dragOffset + alpha = if (draggedProfileId == profile.id) 0.92f else 1f + } + .pointerInput(profile.id) { + detectDragGesturesAfterLongPress( + onDragStart = { draggedProfileId = profile.id }, + onDragCancel = { + dragOffset = 0f + draggedProfileId = null + val order = profiles.map(ProxyProfile::id) + scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) } + }, + onDragEnd = { + dragOffset = 0f + draggedProfileId = null + val order = profiles.map(ProxyProfile::id) + scope.launch(ConfigIoDispatcher) { store.reorderProfiles(order) } + }, + onDrag = { change, amount -> + change.consume() + dragOffset += amount.y + val currentIndex = profiles.indexOfFirst { it.id == profile.id } + val currentInfo = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == profile.id } ?: return@detectDragGesturesAfterLongPress + val targetY = currentInfo.offset + currentInfo.size / 2f + dragOffset + val targetInfo = listState.layoutInfo.visibleItemsInfo.firstOrNull { + it.key != profile.id && targetY.toInt() in it.offset..(it.offset + it.size) + } ?: return@detectDragGesturesAfterLongPress + val targetIndex = profiles.indexOfFirst { it.id == targetInfo.key } + if (currentIndex >= 0 && targetIndex >= 0 && currentIndex != targetIndex) { + dragOffset += currentInfo.offset - targetInfo.offset + profiles.add(targetIndex, profiles.removeAt(currentIndex)) + } + }, + ) + } + .semantics { + customActions = buildList { + if (profiles.firstOrNull()?.id != profile.id) { + add(CustomAccessibilityAction("Move up") { moveProfile(profile.id, -1) }) + } + if (profiles.lastOrNull()?.id != profile.id) { + add(CustomAccessibilityAction("Move down") { moveProfile(profile.id, 1) }) + } + } + }, + onConfigure = { edit(profile) }, + onClone = { + scope.launch { + withContext(ConfigIoDispatcher) { store.cloneProfile(profile.id) } + refresh() + } + }, + onDelete = { deleteProfile = profile }, + ) + } + importError?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } } + item { Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 16.dp)) } + } + } + } + + deleteProfile?.let { profile -> + AlertDialog( + onDismissRequest = { deleteProfile = null }, + title = { Text("Delete ${profile.displayName}?") }, + text = { Text("The profile and its encrypted credentials will be removed.") }, + confirmButton = { TextButton(onClick = { + deleteProfile = null + scope.launch { + val reconnect = withContext(ConfigIoDispatcher) { + val needed = store.isConnectionDesired() && store.connectionProfile().id == profile.id + store.deleteProfile(profile.id) + needed + } + refresh() + if (reconnect) ProxyVpnService.reconnect(activity) + } + }, enabled = profiles.size > 1) { Text("Delete") } }, + dismissButton = { TextButton(onClick = { deleteProfile = null }) { Text("Cancel") } }, + ) + } + if (showImportFilterNotice) { + AlertDialog( + onDismissRequest = { showImportFilterNotice = false }, + title = { Text("Only HTTPS proxies were imported") }, + text = { Text("Imported $importedProfileCount HTTPS proxy profile(s). Skipped $skippedNonHttps non-HTTPS URL(s).") }, + confirmButton = { TextButton(onClick = { showImportFilterNotice = false }) { Text("Continue") } }, + ) + } + if (showExportDialog) { + AlertDialog( + onDismissRequest = { showExportDialog = false }, + title = { Text("Export configuration") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Format") + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(exportFormat == ConfigExportFormat.JSON, { exportFormat = ConfigExportFormat.JSON }) + Text("JSON · all MegaProxy settings") + } + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(exportFormat == ConfigExportFormat.PROXY_LIST, { exportFormat = ConfigExportFormat.PROXY_LIST }) + Text("ProxyList.txt · compatible proxy fields only") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(includePasswords, { includePasswords = it }) + Text("Include passwords") + } + if (!includePasswords) { + Text("Passwords will be left empty and must be entered after import.", style = MaterialTheme.typography.bodySmall) + } + if (exportFormat == ConfigExportFormat.JSON) Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(includePrivateKeys, { includePrivateKeys = it }) + Text("Include SSH private keys") + } + if (includePrivateKeys) Text("Private keys will be exported in plain text.", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { TextButton(onClick = { + showExportDialog = false + if (includePasswords || includePrivateKeys) showPasswordExportWarning = true else launchExport() + }) { Text("Export") } }, + dismissButton = { TextButton(onClick = { showExportDialog = false }) { Text("Cancel") } }, + ) + } + if (showPasswordExportWarning) { + AlertDialog( + onDismissRequest = { showPasswordExportWarning = false }, + title = { Text("Export secrets in plain text?") }, + text = { Text("Anyone with access to the exported file will be able to read the selected passwords or SSH private keys. Store and transfer it securely.") }, + confirmButton = { TextButton(onClick = { + showPasswordExportWarning = false + launchExport() + }) { Text("Export anyway") } }, + dismissButton = { TextButton(onClick = { showPasswordExportWarning = false }) { Text("Cancel") } }, + ) + } + transferMessage?.let { message -> + AlertDialog( + onDismissRequest = { transferMessage = null }, + title = { Text("Configuration transfer") }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = { transferMessage = null }) { Text("OK") } }, + ) + } + pendingUnsafeImport?.let { configuration -> + AlertDialog( + onDismissRequest = { pendingUnsafeImport = null }, + title = { Text("Import insecure verification settings?") }, + text = { Text("One or more profiles disable HTTPS certificate or SSH host-key verification. This permits server impersonation and should only be used for servers you control.") }, + confirmButton = { TextButton(onClick = { + pendingUnsafeImport = null + applyJsonImport(configuration) + }) { Text("Import anyway") } }, + dismissButton = { TextButton(onClick = { pendingUnsafeImport = null }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun ProfileCard( + profile: ProxyProfile, + modifier: Modifier = Modifier, + onConfigure: () -> Unit, + onClone: () -> Unit, + onDelete: () -> Unit, +) { + val background = Color(ProfileColors.argb[Math.floorMod(profile.colorIndex, ProfileColors.argb.size)]) + val foreground = if (background.luminance() > 0.45f) Color.Black else Color.White + Card( + colors = CardDefaults.cardColors(containerColor = background, contentColor = foreground), + modifier = modifier.fillMaxWidth(), + ) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (profile.flagEmoji.isNotEmpty()) { + Surface( + color = Color.White, contentColor = Color.Black, + shape = RoundedCornerShape(6.dp), + border = BorderStroke(1.dp, Color.Black.copy(alpha = 0.45f)), + ) { Text(profile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)) } + } + Column(Modifier.weight(1f)) { + Text(profile.displayName, style = MaterialTheme.typography.titleMedium) + Text("${profile.config.host}:${profile.config.port}", style = MaterialTheme.typography.bodySmall) + } + Surface( + color = foreground.copy(alpha = 0.16f), + contentColor = foreground, + shape = RoundedCornerShape(50), + border = BorderStroke(1.dp, foreground.copy(alpha = 0.55f)), + ) { + Text( + when (profile.config.type) { + ProxyType.HTTPS -> "HTTPS" + ProxyType.SSH -> "SSH" + ProxyType.SSH_JUMP -> "SSH + Jump" + }, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), + ) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onConfigure) { Text("Configure", color = foreground) } + TextButton(onClick = onClone) { Text("Clone", color = foreground) } + TextButton(onClick = onDelete) { Text("Delete", color = foreground) } + } + } + } +} diff --git a/app/src/main/java/net/megaproxy487/SettingsActivities.kt b/app/src/main/java/net/megaproxy487/SettingsActivities.kt new file mode 100644 index 0000000..7c85ce0 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/SettingsActivities.kt @@ -0,0 +1,544 @@ +package net.megaproxy487 + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.model.Ja3Spec +import net.megaproxy487.model.TlsProfile +import net.megaproxy487.model.SshProfile +import net.megaproxy487.model.SshAuthMode +import net.megaproxy487.model.FailoverMode +import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.vpn.VpnRuntimeState +import net.megaproxy487.vpn.readAlwaysOnVpnStatus +import net.megaproxy487.ui.theme.MegaProxyTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ProxySettingsActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MegaProxyTheme { SettingsHomeScreen(this) } } + } +} + +class AlwaysOnSettingsActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MegaProxyTheme { AlwaysOnSettingsScreen(this) } } + } +} + +class TlsFingerprintActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MegaProxyTheme { TlsFingerprintScreen(this) } } + } +} + +class FailoverSettingsActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MegaProxyTheme { FailoverSettingsScreen(this) } } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsHomeScreen(activity: Activity) { + val lifecycleOwner = LocalLifecycleOwner.current + val powerManager = remember { activity.getSystemService(PowerManager::class.java) } + var batteryOptimizationDisabled by remember { + mutableStateOf(powerManager.isIgnoringBatteryOptimizations(activity.packageName)) + } + var showLanguageDialog by remember { mutableStateOf(false) } + var supportError by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + batteryOptimizationDisabled = powerManager.isIgnoringBatteryOptimizations(activity.packageName) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + SettingsScaffold(activity, stringResource(R.string.settings)) { + Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleMedium) + SettingsButton(stringResource(R.string.profiles), stringResource(R.string.profiles_description)) { activity.startActivity(Intent(activity, ProfilesActivity::class.java)) } + SettingsButton(stringResource(R.string.always_on_vpn), stringResource(R.string.always_on_description)) { activity.startActivity(Intent(activity, AlwaysOnSettingsActivity::class.java)) } + SettingsButton(stringResource(R.string.fingerprints), stringResource(R.string.fingerprints_description)) { activity.startActivity(Intent(activity, TlsFingerprintActivity::class.java)) } + SettingsButton(stringResource(R.string.split_tunneling), stringResource(R.string.split_tunneling_description)) { activity.startActivity(Intent(activity, SplitTunnelActivity::class.java)) } + SettingsButton(stringResource(R.string.failover), stringResource(R.string.failover_description)) { activity.startActivity(Intent(activity, FailoverSettingsActivity::class.java)) } + Text(stringResource(R.string.diagnostics), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + SettingsButton(stringResource(R.string.visibility), stringResource(R.string.visibility_description)) { activity.startActivity(Intent(activity, VisibilityActivity::class.java)) } + if (!batteryOptimizationDisabled) { + SettingsButton(stringResource(R.string.battery_settings), stringResource(R.string.battery_settings_description)) { + activity.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${activity.packageName}") + }) + } + } + SettingsButton(stringResource(R.string.diagnostic_log), stringResource(R.string.diagnostic_log_description)) { activity.startActivity(Intent(activity, DiagnosticLogActivity::class.java)) } + Text(stringResource(R.string.appearance_and_language), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + SettingsButton( + stringResource(R.string.language), + "${stringResource(R.string.language_description)} · ${if (AppLanguageManager.current(activity) == AppLanguage.RUSSIAN) stringResource(R.string.language_russian) else stringResource(R.string.language_english)}", + ) { showLanguageDialog = true } + Text(stringResource(R.string.support), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) + SettingsButton(stringResource(R.string.feedback), stringResource(R.string.feedback_description)) { + scope.launch { + runCatching { + val status = readAlwaysOnVpnStatus(activity) + val intent = withContext(Dispatchers.IO) { + FeedbackEmail.createIntent( + activity, + VpnRuntimeState.connection.value, + VpnRuntimeState.alwaysOn.value || status.enabled, + VpnRuntimeState.lockdown.value || status.lockdown, + ) + } + activity.startActivity(intent) + }.onFailure { supportError = it.message ?: "Could not open an email client" } + } + } + SettingsButton(stringResource(R.string.github_issues), stringResource(R.string.github_issues_description)) { + runCatching { + activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/andre487/AndroidMegaProxy/issues"))) + }.onFailure { supportError = activity.getString(R.string.no_browser) } + } + supportError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Text( + stringResource(R.string.version_and_commit, BuildConfig.VERSION_NAME, BuildConfig.GIT_COMMIT_HASH), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(top = 8.dp), + ) + } + + if (showLanguageDialog) { + val selected = AppLanguageManager.current(activity) + AlertDialog( + onDismissRequest = { showLanguageDialog = false }, + title = { Text(stringResource(R.string.language)) }, + text = { + Column { + AppLanguage.entries.forEach { language -> + val label = if (language == AppLanguage.RUSSIAN) { + stringResource(R.string.language_russian) + } else { + stringResource(R.string.language_english) + } + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = selected == language, + role = Role.RadioButton, + onValueChange = { + AppLanguageManager.set(activity, language) + showLanguageDialog = false + activity.recreate() + }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.RadioButton(selected == language, null) + Text(label) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = { showLanguageDialog = false }) { Text(stringResource(android.R.string.cancel)) } }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FailoverSettingsScreen(activity: Activity) { + val store = remember { ConfigStore(activity) } + val scope = rememberCoroutineScope() + var settings by remember { mutableStateOf(store.globalConnectionSettings()) } + var expanded by remember { mutableStateOf(false) } + var pendingAll by remember { mutableStateOf(false) } + val alwaysOn = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled } + fun save(mode: FailoverMode = settings.failoverMode, ids: List = settings.failoverProfileIds) { + settings = settings.copy(failoverMode = mode, failoverProfileIds = ids) + val snapshot = settings + scope.launch(ConfigIoDispatcher) { store.saveGlobalConnectionSettings(snapshot) } + } + SettingsScaffold(activity, "Failover") { + ExposedDropdownMenuBox(expanded, { expanded = it }) { + OutlinedTextField(settings.failoverMode.title, {}, readOnly = true, label = { Text("Failover mode") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + DropdownMenu(expanded, { expanded = false }) { + FailoverMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(mode.title) }, onClick = { + expanded = false + if (mode == FailoverMode.ALL) pendingAll = true else save(mode = mode) + }) } + } + } + Text("Failover is only triggered after repeated timeout, reset or silent-drop signals. Authentication, host-key, certificate and configuration errors never trigger it.", style = MaterialTheme.typography.bodySmall) + if (settings.failoverMode == FailoverMode.SELECTED) { + Text("Fallback profiles", style = MaterialTheme.typography.titleMedium) + Text("Profiles are tried in the same order as the Profiles screen.", style = MaterialTheme.typography.bodySmall) + store.sortedProfiles().forEach { profile -> + val checked = profile.id in settings.failoverProfileIds + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = checked, + onValueChange = { checkedValue -> + val ids = if (checkedValue) settings.failoverProfileIds + profile.id else settings.failoverProfileIds - profile.id + save(ids = ids) + }, + role = Role.Checkbox, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked, null) + Text(profile.displayNameWithFlag) + } + } + val usableFallbacks = store.sortedProfiles().count { it.id in settings.failoverProfileIds } + if (usableFallbacks < 2) { + Text( + "Select at least two profiles. With fewer profiles, failover may have nowhere to switch.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (settings.failoverMode != FailoverMode.DISABLED) { + Text("Warning: switching profiles may change the apparent country and public exit IP.", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + if (alwaysOn) Text( + if (settings.failoverMode == FailoverMode.ALL) + "Always-on VPN remains active during automatic switching. Every switch is reported in the persistent notification and on the main screen." + else + "Always-on VPN remains active during automatic switching. The persistent notification and the main screen show the profile actually in use.", + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (pendingAll) AlertDialog( + onDismissRequest = { pendingAll = false }, + title = { Text("Enable global failover?") }, + text = { Text("MegaProxy will automatically try any configured profile when blocking is suspected. This can change your location and public exit IP. Individual switches will not require confirmation.") }, + confirmButton = { TextButton(onClick = { pendingAll = false; save(mode = FailoverMode.ALL) }) { Text("Enable") } }, + dismissButton = { TextButton(onClick = { pendingAll = false }) { Text("Cancel") } }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AlwaysOnSettingsScreen(activity: Activity) { + val store = remember { ConfigStore(activity) } + val scope = rememberCoroutineScope() + var expanded by remember { mutableStateOf(false) } + var selected by remember { mutableStateOf(store.alwaysOnProfile()) } + val alwaysOnActive = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled } + SettingsScaffold(activity, "Always-on VPN") { + ExposedDropdownMenuBox(expanded, { expanded = it }) { + OutlinedTextField( + selected.displayNameWithFlag, {}, readOnly = true, + label = { Text("Profile for Always-on VPN") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + ) + DropdownMenu(expanded, { expanded = false }) { + store.sortedProfiles().forEach { profile -> + DropdownMenuItem(text = { Text(profile.displayNameWithFlag) }, onClick = { + selected = profile + scope.launch(ConfigIoDispatcher) { store.setAlwaysOnProfile(profile.id) } + expanded = false + if (alwaysOnActive && ProxyVpnService.isRunning) { + ProxyVpnService.switchProfile(activity, profile.id, true) + } + }) + } + } + } + Text("This profile is used when Android starts MegaProxy as an Always-on VPN.", style = MaterialTheme.typography.bodySmall) + if (alwaysOnActive) { + Text("Always-on is active. Selecting another profile reconnects immediately.", color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall) + } + SettingsButton("Open Android Always-on VPN settings") { + activity.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TlsFingerprintScreen(activity: Activity) { + val store = remember { ConfigStore(activity) } + val scope = rememberCoroutineScope() + var settings by remember { mutableStateOf(store.globalConnectionSettings()) } + var expanded by remember { mutableStateOf(false) } + var sshExpanded by remember { mutableStateOf(false) } + var sshAuthExpanded by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var showReconnectPrompt by remember { mutableStateOf(false) } + var showAlwaysOnNotice by remember { mutableStateOf(false) } + var deferred by remember { mutableStateOf(false) } + + fun saveSettings(updated: net.megaproxy487.model.GlobalConnectionSettings) { + settings = updated + val snapshot = settings + scope.launch(ConfigIoDispatcher) { + store.saveGlobalConnectionSettings(snapshot) + if (ProxyVpnService.isRunning) store.markPendingReconnect() + } + if (ProxyVpnService.isRunning && !deferred) { + if (ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled) { + deferred = true; showAlwaysOnNotice = true + } else showReconnectPrompt = true + } + } + + fun save( + profile: TlsProfile = settings.tlsProfile, + customJa3: String = settings.customJa3, + sshProfile: SshProfile = settings.sshProfile, + ) { + saveSettings(settings.copy(tlsProfile = profile, customJa3 = customJa3, sshProfile = sshProfile)) + error = if (profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null) { + "JA3 must contain five fields: version,ciphers,extensions,groups,points" + } else null + } + + SettingsScaffold(activity, "Fingerprints") { + Text("HTTPS fingerprint", style = MaterialTheme.typography.titleMedium) + ExposedDropdownMenuBox(expanded, { expanded = it }) { + OutlinedTextField( + settings.tlsProfile.title, {}, readOnly = true, + label = { Text("HTTPS TLS / JA3 profile") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + ) + DropdownMenu(expanded, { expanded = false }) { + TlsProfile.entries.filter { it.available }.forEach { profile -> + DropdownMenuItem(text = { Text(profile.title) }, onClick = { save(profile = profile); expanded = false }) + } + } + } + Text("Default currently uses the Chrome Android fingerprint and may change in a future MegaProxy update.", style = MaterialTheme.typography.bodySmall) + if (settings.tlsProfile == TlsProfile.CUSTOM) { + OutlinedTextField( + settings.customJa3, + { if (it.length <= 8 * 1024) save(customJa3 = it) }, + label = { Text("JA3: version,ciphers,extensions,groups,points") }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Text("SSH fingerprint", style = MaterialTheme.typography.titleMedium) + ExposedDropdownMenuBox(sshExpanded, { sshExpanded = it }) { + OutlinedTextField( + settings.sshProfile.title, {}, readOnly = true, + label = { Text("SSH client profile") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshExpanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + ) + DropdownMenu(sshExpanded, { sshExpanded = false }) { + SshProfile.entries.forEach { profile -> + DropdownMenuItem(text = { Text(profile.title) }, onClick = { + save(sshProfile = profile) + sshExpanded = false + }) + } + } + } + ExposedDropdownMenuBox(sshAuthExpanded, { sshAuthExpanded = it }) { + OutlinedTextField( + settings.sshAuthMode.title, {}, readOnly = true, + label = { Text("SSH authentication") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshAuthExpanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + ) + DropdownMenu(sshAuthExpanded, { sshAuthExpanded = false }) { + SshAuthMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(mode.title) }, onClick = { + saveSettings(settings.copy(sshAuthMode = mode)) + sshAuthExpanded = false + }) } + } + } + IntegerSettingField(settings.sshKeepaliveSeconds, 0..3600, "SSH keepalive, seconds (0 = disabled)") { + saveSettings(settings.copy(sshKeepaliveSeconds = it)) + } + IntegerSettingField(settings.sshMaxChannels, 1..256, "Maximum parallel SSH channels") { + saveSettings(settings.copy(sshMaxChannels = it)) + } + IntegerSettingField(settings.sshRotationMinutes, 0..1440, "Rotate SSH session after minutes (0 = disabled)") { + saveSettings(settings.copy(sshRotationMinutes = it)) + } + IntegerSettingField(settings.sshRotationMb, 0..10240, "Rotate SSH session after MB (0 = disabled)") { + saveSettings(settings.copy(sshRotationMb = it)) + } + Text("The SSH profile controls the client banner and preferred KEX, cipher and MAC ordering.", style = MaterialTheme.typography.bodySmall) + if (showReconnectPrompt) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text("Reconnect to apply these changes to the active VPN.") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { showReconnectPrompt = false; deferred = true }) { Text("Next connection") } + TextButton(onClick = { + showReconnectPrompt = false + deferred = true + ProxyVpnService.reconnect(activity) + }) { Text("Reconnect now") } + } + } + } + } + if (showAlwaysOnNotice) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { + Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Always-on is active. Changes apply on the next connection.", modifier = Modifier.weight(1f)) + TextButton(onClick = { showAlwaysOnNotice = false }) { Text("Dismiss") } + } + } + } + Text("Changes are saved automatically and apply to every profile.", style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +private fun IntegerSettingField(initialValue: Int, range: IntRange, label: String, onValidValue: (Int) -> Unit) { + var text by remember { mutableStateOf(initialValue.toString()) } + val parsed = text.toIntOrNull() + OutlinedTextField( + value = text, + onValueChange = { value -> + if (value.length <= range.last.toString().length && value.all(Char::isDigit)) { + text = value + value.toIntOrNull()?.takeIf { it in range }?.let(onValidValue) + } + }, + label = { Text(label) }, + supportingText = if (text.isNotEmpty() && parsed !in range) { + { Text("Allowed range: ${range.first}–${range.last}") } + } else null, + isError = text.isNotEmpty() && parsed !in range, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsScaffold(activity: Activity, title: String, content: @Composable ColumnScope.() -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { activity.finish() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + ) + }, + contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) { + Column( + Modifier + .widthIn(max = 840.dp) + .fillMaxWidth() + .fillMaxHeight() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = content, + ) + } + } +} + +@Composable +private fun SettingsButton(label: String, description: String? = null, onClick: () -> Unit) { + Card(onClick = onClick, modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.titleSmall) + description?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null) + } + } +} diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt b/app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt new file mode 100644 index 0000000..5c799c7 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt @@ -0,0 +1,263 @@ +package net.megaproxy487 + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.model.GlobalConnectionSettings +import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.vpn.readAlwaysOnVpnStatus +import net.megaproxy487.ui.theme.MegaProxyTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class SplitTunnelActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MegaProxyTheme { SplitTunnelScreen(this) } } + } +} + +private data class AppItem(val packageName: String, val label: String) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SplitTunnelScreen(activity: SplitTunnelActivity) { + val store = remember { ConfigStore(activity) } + var settings by remember { mutableStateOf(store.globalConnectionSettings()) } + var appSearch by remember { mutableStateOf("") } + var showReconnectPrompt by remember { mutableStateOf(false) } + var showAlwaysOnDeferredNotice by remember { mutableStateOf(false) } + var deferChangesUntilNextConnection by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + val alwaysOnActive = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled } + + fun updateSettings(updated: GlobalConnectionSettings) { + val selectedPackagesOnly = settings.selectedPackages != updated.selectedPackages && + settings.copy(selectedPackages = updated.selectedPackages) == updated + val affectsActiveRouting = !(settings.routeAllApps && updated.routeAllApps && selectedPackagesOnly) + settings = updated + coroutineScope.launch(ConfigIoDispatcher) { + store.saveGlobalConnectionSettings(updated) + if (ProxyVpnService.isRunning && affectsActiveRouting) store.markPendingReconnect() + } + if (ProxyVpnService.isRunning && affectsActiveRouting && !deferChangesUntilNextConnection) { + if (alwaysOnActive) { + deferChangesUntilNextConnection = true + showAlwaysOnDeferredNotice = true + } else { + showReconnectPrompt = true + } + } + } + + var apps by remember(activity) { mutableStateOf?>(null) } + LaunchedEffect(activity) { + apps = withContext(Dispatchers.IO) { + val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + activity.packageManager.queryIntentActivities(launcher, 0) + .map { AppItem(it.activityInfo.packageName, it.loadLabel(activity.packageManager).toString()) } + .distinctBy { it.packageName } + .sortedBy { it.label.lowercase() } + } + } + val visibleApps = remember(apps, appSearch) { + val query = appSearch.trim() + val availableApps = apps.orEmpty() + if (query.isEmpty()) availableApps else availableApps.filter { + it.label.contains(query, ignoreCase = true) || it.packageName.contains(query, ignoreCase = true) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.split_tunneling)) }, + navigationIcon = { + IconButton(onClick = { activity.finish() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + bottomBar = { + if (showReconnectPrompt || showAlwaysOnDeferredNotice) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), + modifier = Modifier.fillMaxWidth().padding(12.dp), + ) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text( + if (showAlwaysOnDeferredNotice) "Always-on is active. Routing changes apply on the next connection." + else "Reconnect to apply routing changes to the active VPN.", + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { + showReconnectPrompt = false + showAlwaysOnDeferredNotice = false + deferChangesUntilNextConnection = true + }) { Text(if (showAlwaysOnDeferredNotice) "Dismiss" else "Next connection") } + if (showReconnectPrompt) { + TextButton(onClick = { + showReconnectPrompt = false + deferChangesUntilNextConnection = true + ProxyVpnService.reconnect(activity) + }) { Text("Reconnect now") } + } + } + } + } + } + }, + // Use one inset source. Applying safeDrawing in Scaffold and imePadding to + // its child can double-apply or misreport the IME area on some OEM builds. + contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), + ) { padding -> + LazyColumn( + Modifier + .fillMaxSize() + .padding(padding), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + item { Text(stringResource(R.string.routing_mode), style = MaterialTheme.typography.titleMedium) } + item { + Column(Modifier.selectableGroup()) { + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable( + selected = !settings.routeAllApps, + onClick = { updateSettings(settings.copy(routeAllApps = false)) }, + role = Role.RadioButton, + ), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + RadioButton(!settings.routeAllApps, null) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.split_tunneling)) + Text("Only selected applications use the proxy.", style = MaterialTheme.typography.bodySmall) + } + } + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable( + selected = settings.routeAllApps, + onClick = { updateSettings(settings.copy(routeAllApps = true)) }, + role = Role.RadioButton, + ), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + RadioButton(settings.routeAllApps, null) + Column(Modifier.weight(1f)) { + Text("Global VPN") + Text("All applications use the proxy. This is the default mode.", style = MaterialTheme.typography.bodySmall) + } + } + } + } + item { Text("UDP and QUIC are blocked; DNS uses the profile's configured DoH endpoint.", style = MaterialTheme.typography.bodySmall) } + item { + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = settings.bypassLocalNetworks, + onValueChange = { updateSettings(settings.copy(bypassLocalNetworks = it)) }, + role = Role.Checkbox, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Bypass local networks") + Text("Direct TCP access to private and link-local IP addresses.", style = MaterialTheme.typography.bodySmall) + } + Checkbox(settings.bypassLocalNetworks, null) + } + } + if (!settings.routeAllApps) { + item { Text(stringResource(R.string.applications), style = MaterialTheme.typography.titleMedium) } + if (apps == null) item { + Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator() + } + } + item { + OutlinedTextField( + value = appSearch, + onValueChange = { appSearch = it }, + label = { Text("Search applications") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + items(visibleApps, key = { it.packageName }) { app -> + val checked = app.packageName in settings.selectedPackages + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = checked, + onValueChange = { checkedValue -> + updateSettings(settings.copy(selectedPackages = if (checked) { + if (checkedValue) settings.selectedPackages else settings.selectedPackages - app.packageName + } else { + if (checkedValue) settings.selectedPackages + app.packageName else settings.selectedPackages + })) + }, + role = Role.Checkbox, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Text(app.label, Modifier.weight(1f)) + Checkbox(checked, null) + } + } + } + item { Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) } + } + } +} diff --git a/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt b/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt new file mode 100644 index 0000000..c53b170 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt @@ -0,0 +1,61 @@ +package net.megaproxy487 + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.setContent +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import net.megaproxy487.ui.theme.MegaProxyTheme +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.vpn.ProxyVpnService + +class SshHostKeyActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val profileId = intent.getStringExtra(EXTRA_PROFILE_ID).orEmpty() + val hop = intent.getStringExtra(EXTRA_HOP).orEmpty() + val algorithm = intent.getStringExtra(EXTRA_ALGORITHM).orEmpty() + val fingerprint = intent.getStringExtra(EXTRA_FINGERPRINT).orEmpty() + val changed = intent.getBooleanExtra(EXTRA_CHANGED, false) + val testOnly = intent.getBooleanExtra(EXTRA_TEST_ONLY, false) + fun reject() { + // A normal/Always-on connection remains paused with an actionable persistent + // notification. Tests are disposable and may release their temporary service. + if (testOnly) ProxyVpnService.dismissHostKeyPrompt(this) + finish() + } + setContent { + MegaProxyTheme { + AlertDialog( + onDismissRequest = ::reject, + title = { Text(if (changed) "SSH host key changed" else "Trust SSH host key?") }, + text = { Text(buildString { + if (changed) append("Warning: the previously trusted key for the $hop host has changed. This can indicate a server reinstall or a man-in-the-middle attack. Verify the fingerprint through a trusted channel before replacing it.\n\n") + else append("This is the first connection to the $hop SSH host. Verify its fingerprint through a trusted channel.\n\n") + append("Algorithm: $algorithm\nFingerprint: $fingerprint") + }) }, + confirmButton = { TextButton(onClick = { + if (ConfigStore(this).trustSshHostKey(profileId, hop, fingerprint)) { + if (testOnly) ProxyVpnService.test(this) else ProxyVpnService.reconnect(this) + } + finish() + }) { Text(if (changed) "Replace trusted key" else "Trust and connect") } }, + dismissButton = { TextButton(onClick = ::reject) { Text("Cancel") } }, + ) + } + } + } + + companion object { + const val EXTRA_PROFILE_ID = "profile_id" + const val EXTRA_HOP = "hop" + const val EXTRA_ALGORITHM = "algorithm" + const val EXTRA_FINGERPRINT = "fingerprint" + const val EXTRA_CHANGED = "changed" + const val EXTRA_TEST_ONLY = "test_only" + } +} diff --git a/app/src/main/java/net/megaproxy487/VisibilityActivity.kt b/app/src/main/java/net/megaproxy487/VisibilityActivity.kt new file mode 100644 index 0000000..c6c3c59 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/VisibilityActivity.kt @@ -0,0 +1,223 @@ +package net.megaproxy487 + +import android.app.Activity +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import net.megaproxy487.ui.theme.MegaProxyTheme +import net.megaproxy487.data.ConfigStore +import java.net.NetworkInterface + +class VisibilityActivity : LocalizedActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { MegaProxyTheme { VisibilityScreen(this) } } + } +} + +private enum class VisibilityState(val label: String) { + DETECTED("Detected"), + SELECTED_APPS_ONLY("Selected apps only"), + NOT_DETECTED("Not detected"), +} + +private data class VisibilityCheck( + val title: String, + val state: VisibilityState, + val detail: String, +) + +private data class VisibilityReport( + val global: List, + val selectedApps: List, + val splitTunneling: Boolean, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VisibilityScreen(activity: Activity) { + var report by remember { mutableStateOf(buildVisibilityReport(activity)) } + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.visibility)) }, + navigationIcon = { + IconButton(onClick = activity::finish) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + contentWindowInsets = WindowInsets.safeDrawing, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Local checks show signals that another Android app may observe. No network request, location lookup, or external reputation service is used.", + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = { report = buildVisibilityReport(activity) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.run_checks_again)) + } + VisibilitySection("Globally observable signals", report.global) + if (report.splitTunneling) { + Text( + "The signals below belong to the VPN network. With split tunneling, Android exposes that network to selected apps; excluded apps use the underlying network instead.", + style = MaterialTheme.typography.bodySmall, + ) + } + VisibilitySection( + if (report.splitTunneling) "Visible to selected apps" else "VPN network signals", + report.selectedApps, + ) + Text( + "A detected signal is not proof by itself. Android VPN APIs intentionally expose some VPN state, and legitimate security or filtering apps can produce the same signals.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + } +} + +@Composable +private fun VisibilitySection(title: String, checks: List) { + Text(title, style = MaterialTheme.typography.titleMedium) + checks.forEach { check -> + Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(check.title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) + VisibilityBadge(check.state) + } + Text(check.detail, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun VisibilityBadge(state: VisibilityState) { + val background = when (state) { + VisibilityState.DETECTED -> Color(0xFFB3261E) + VisibilityState.SELECTED_APPS_ONLY -> Color(0xFFF9A825) + VisibilityState.NOT_DETECTED -> Color(0xFF2E7D32) + } + val foreground = if (state == VisibilityState.SELECTED_APPS_ONLY) Color.Black else Color.White + Surface(color = background, contentColor = foreground, shape = RoundedCornerShape(12.dp)) { + Text(state.label, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) + } +} + +private fun buildVisibilityReport(context: Context): VisibilityReport { + val connectivity = context.getSystemService(ConnectivityManager::class.java) + val networks = runCatching { connectivity.allNetworks.toList() }.getOrDefault(emptyList()) + val capabilities = networks.mapNotNull { network -> + runCatching { connectivity.getNetworkCapabilities(network) }.getOrNull() + } + val vpnCapabilities = capabilities.filter { it.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } + val vpnTransportVisible = vpnCapabilities.isNotEmpty() + val vpnInfoVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && vpnCapabilities.any { + runCatching { it.transportInfo?.javaClass?.simpleName == "VpnTransportInfo" }.getOrDefault(false) + } + val vpnWithoutNotVpn = vpnCapabilities.any { !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } + + val linkProperties = networks.mapNotNull { network -> + runCatching { connectivity.getLinkProperties(network) }.getOrNull() + } + val linkProxyVisible = linkProperties.any { it.httpProxy != null } + val systemProxyVisible = listOf("http.proxyHost", "https.proxyHost", "socksProxyHost").any { + System.getProperty(it).orEmpty().isNotBlank() + } + + val interfaces = runCatching { + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().filter { it.isUp } + }.getOrDefault(emptyList()) + val vpnName = Regex("^(tun|tap|wg|ppp|ipsec)", RegexOption.IGNORE_CASE) + val vpnInterfaces = interfaces.filter { vpnName.containsMatchIn(it.name) } + val unusualMtu = vpnInterfaces.filter { it.mtu !in setOf(1280, 1500) } + val virtualDefaultRoute = linkProperties.any { properties -> + properties.routes.any { it.isDefaultRoute && vpnName.containsMatchIn(it.`interface`.orEmpty()) } + } + val virtualDns = linkProperties.any { properties -> + properties.dnsServers.any { address -> + address.isSiteLocalAddress || address.isLinkLocalAddress || address.isLoopbackAddress + } + } + val settings = ConfigStore(context).globalConnectionSettings() + val splitTunneling = !settings.routeAllApps + + fun check(title: String, detected: Boolean, yes: String, no: String) = VisibilityCheck( + title, + if (detected) VisibilityState.DETECTED else VisibilityState.NOT_DETECTED, + if (detected) yes else no, + ) + fun vpnCheck(title: String, detected: Boolean, yes: String, no: String) = VisibilityCheck( + title, + when { + !detected -> VisibilityState.NOT_DETECTED + splitTunneling -> VisibilityState.SELECTED_APPS_ONLY + else -> VisibilityState.DETECTED + }, + if (detected && splitTunneling) "$yes Excluded apps use their underlying network." else if (detected) yes else no, + ) + + return VisibilityReport( + global = listOf( + check("System proxy properties", systemProxyVisible, "A process-wide proxy property is visible.", "No process-wide proxy property is visible."), + check("VPN-like interface name", vpnInterfaces.isNotEmpty(), "An active interface has a tun, tap, wg, ppp, or ipsec-style name.", "No active interface has a common VPN-style name."), + check("Unusual VPN MTU", unusualMtu.isNotEmpty(), "A VPN-style interface uses an MTU other than 1280 or 1500.", "No unusual MTU was found on VPN-style interfaces."), + VisibilityCheck("Local MITM certificate", VisibilityState.NOT_DETECTED, "MegaProxy installs no CA certificate and performs no TLS interception."), + ), + selectedApps = listOf( + vpnCheck("VPN transport", vpnTransportVisible, "Android exposes a network with TRANSPORT_VPN.", "No VPN transport is currently exposed."), + vpnCheck("VPN transport information", vpnInfoVisible, "Android exposes VpnTransportInfo for the VPN network.", "VpnTransportInfo is not exposed on the currently visible networks."), + vpnCheck("NOT_VPN capability", vpnWithoutNotVpn, "The VPN network does not have NET_CAPABILITY_NOT_VPN.", "No visible network is identified by this capability check as a VPN."), + vpnCheck("VPN link proxy", linkProxyVisible, "A proxy setting is attached to the VPN network.", "No proxy setting is attached to the VPN network."), + vpnCheck("Virtual default route", virtualDefaultRoute, "A visible default route uses a VPN-style interface.", "No visible default route uses a VPN-style interface."), + vpnCheck("Virtual DNS address", virtualDns, "The VPN network exposes a private, link-local, or loopback DNS address.", "The VPN network does not expose a local DNS address."), + ), + splitTunneling = splitTunneling, + ) +} diff --git a/app/src/main/java/net/megaproxy487/data/ConfigIo.kt b/app/src/main/java/net/megaproxy487/data/ConfigIo.kt new file mode 100644 index 0000000..d6d75b9 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/ConfigIo.kt @@ -0,0 +1,10 @@ +package net.megaproxy487.data + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.Executors + +/** Ordered background dispatcher for configuration encryption, JSON and persistence. */ +val ConfigIoDispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { task -> + Thread(task, "megaproxy-config-io").apply { isDaemon = true } +}.asCoroutineDispatcher() diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt new file mode 100644 index 0000000..59a8105 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -0,0 +1,534 @@ +package net.megaproxy487.data + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import net.megaproxy487.model.DnsProvider +import net.megaproxy487.model.ProfileColors +import net.megaproxy487.model.ProfileColorMatcher +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyProfile +import net.megaproxy487.model.ProxyType +import net.megaproxy487.model.SshProfile +import net.megaproxy487.model.SshAuthMode +import net.megaproxy487.model.FailoverMode +import net.megaproxy487.model.TlsProfile +import net.megaproxy487.model.GlobalConnectionSettings +import org.json.JSONArray +import org.json.JSONObject +import java.security.KeyStore +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class ConfigStore(context: Context) { + private val prefs = context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE) + + @Synchronized + fun profiles(): List { + ensureMigrated() + val decoded = decodeProfiles(prefs.getString(PROFILES, null)).ifEmpty { + val recovered = createInitialProfile() + prefs.edit() + .putString(PROFILES, encodeProfiles(listOf(recovered))) + .putString(ACTIVE_PROFILE_ID, recovered.id) + .putString(ALWAYS_ON_PROFILE_ID, recovered.id) + .putString(CONNECTION_PROFILE_ID, recovered.id) + .putBoolean(FAILOVER_ACTIVE, false) + .remove(FAILOVER_NOTICE) + .apply() + listOf(recovered) + } + if (prefs.getInt(FLAG_COLOR_VERSION, 0) >= CURRENT_FLAG_COLOR_VERSION) return decoded + val recolored = decoded.map { profile -> + if (profile.countryCode.isEmpty()) profile + else profile.copy( + colorIndex = ProfileColorMatcher.colorIndexForFlag(profile.countryCode, profile.colorIndex), + ) + } + prefs.edit() + .putString(PROFILES, encodeProfiles(recolored)) + .putInt(FLAG_COLOR_VERSION, CURRENT_FLAG_COLOR_VERSION) + .apply() + return recolored + } + + fun activeProfile(): ProxyProfile = profile(activeProfileId()) ?: profiles().first() + + fun alwaysOnProfile(): ProxyProfile = profile(alwaysOnProfileId()) ?: profiles().first() + + fun sortedProfiles(): List = profiles() + + @Synchronized + fun reorderProfiles(orderedIds: List) { + val current = profiles() + val byId = current.associateBy(ProxyProfile::id) + val reordered = orderedIds.mapNotNull(byId::get) + current.filter { it.id !in orderedIds } + if (reordered.map(ProxyProfile::id) == current.map(ProxyProfile::id)) return + writeProfiles(reordered) + } + + fun profile(id: String): ProxyProfile? = profiles().firstOrNull { it.id == id } + + fun activeProfileId(): String { + ensureMigrated() + return prefs.getString(ACTIVE_PROFILE_ID, null) ?: profiles().first().id + } + + fun alwaysOnProfileId(): String { + ensureMigrated() + return prefs.getString(ALWAYS_ON_PROFILE_ID, null) ?: activeProfileId() + } + + fun setActiveProfile(id: String) { + if (profile(id) != null) prefs.edit().putString(ACTIVE_PROFILE_ID, id).apply() + } + + fun setAlwaysOnProfile(id: String) { + if (profile(id) != null) prefs.edit() + .putString(ALWAYS_ON_PROFILE_ID, id) + .putBoolean(FAILOVER_ACTIVE, false) + .apply() + } + + fun connectionProfile(): ProxyProfile = + profile(prefs.getString(CONNECTION_PROFILE_ID, null).orEmpty()) ?: activeProfile() + + /** Identifier-only lookup that never decrypts or parses profile payloads. */ + fun connectionProfileId(): String = + prefs.getString(CONNECTION_PROFILE_ID, null).orEmpty().ifEmpty(::activeProfileId) + + fun connectionConfig(): ProxyConfig = globalConnectionSettings().applyTo(connectionProfile().config) + + fun setConnectionProfile(id: String) { + if (profile(id) != null) prefs.edit().putString(CONNECTION_PROFILE_ID, id).apply() + } + + fun failoverNotice(): String? = prefs.getString(FAILOVER_NOTICE, null) + + fun isFailoverActive(): Boolean = prefs.getBoolean(FAILOVER_ACTIVE, false) + + fun setFailoverState(active: Boolean, notice: String?) { + prefs.edit().putBoolean(FAILOVER_ACTIVE, active).apply { + if (notice == null) remove(FAILOVER_NOTICE) else putString(FAILOVER_NOTICE, notice) + }.apply() + } + + fun hasPendingReconnect(): Boolean = pendingReconnectToken() != null + + fun pendingReconnectToken(): String? = prefs.getString(PENDING_RECONNECT, null) + + fun markPendingReconnect() { + prefs.edit().putString(PENDING_RECONNECT, UUID.randomUUID().toString()).apply() + } + + fun clearPendingReconnect(appliedToken: String?) { + if (appliedToken != null && pendingReconnectToken() == appliedToken) { + prefs.edit().remove(PENDING_RECONNECT).apply() + } + } + + @Synchronized + fun globalConnectionSettings(): GlobalConnectionSettings { + ensureMigrated() + val stored = prefs.getString(GLOBAL_CONNECTION_SETTINGS, null) + if (stored != null) { + migrateGlobalIpv6ToProfiles(stored) + val decoded = decodeGlobalConnectionSettings(stored) + if (!runCatching { JSONObject(stored).has("sshProfile") }.getOrDefault(false)) { + val upgraded = decoded.copy(sshProfile = activeProfile().config.sshProfile) + saveGlobalConnectionSettings(upgraded) + return upgraded + } + return decoded + } + val source = activeProfile().config + val migrated = GlobalConnectionSettings( + tlsProfile = source.profile.takeIf { it.available } ?: TlsProfile.DEFAULT, + sshProfile = source.sshProfile, + customJa3 = source.customJa3, + selectedPackages = source.selectedPackages, + routeAllApps = source.routeAllApps, + bypassLocalNetworks = source.bypassLocalNetworks, + ) + saveGlobalConnectionSettings(migrated) + prefs.edit().putBoolean(IPV6_PROFILE_MIGRATED, true).apply() + return migrated + } + + @Synchronized + private fun migrateGlobalIpv6ToProfiles(storedSettings: String) { + if (prefs.getBoolean(IPV6_PROFILE_MIGRATED, false)) return + val enabled = runCatching { JSONObject(storedSettings).optBoolean("allowIpv6", false) }.getOrDefault(false) + writeProfiles(profiles().map { it.copy(config = it.config.copy(allowIpv6 = enabled)) }) + prefs.edit().putBoolean(IPV6_PROFILE_MIGRATED, true).apply() + } + + fun saveGlobalConnectionSettings(settings: GlobalConnectionSettings) { + val normalized = settings.copy( + tlsProfile = settings.tlsProfile.takeIf { it.available } ?: TlsProfile.DEFAULT, + sshKeepaliveSeconds = settings.sshKeepaliveSeconds.coerceIn(0, 3600), + sshMaxChannels = settings.sshMaxChannels.coerceIn(1, 256), + sshRotationMinutes = settings.sshRotationMinutes.coerceIn(0, 1440), + sshRotationMb = settings.sshRotationMb.coerceIn(0, 10240), + failoverProfileIds = settings.failoverProfileIds.distinct(), + ) + prefs.edit().putString(GLOBAL_CONNECTION_SETTINGS, encodeGlobalConnectionSettings(normalized)).apply() + } + + @Synchronized + fun addProfile(): ProxyProfile { + val existing = profiles() + val profile = ProxyProfile( + id = UUID.randomUUID().toString(), + colorIndex = nextColorIndex(existing), + config = ProxyConfig(port = 443), + ) + writeProfiles(existing + profile) + return profile + } + + @Synchronized + fun cloneProfile(id: String): ProxyProfile? { + val existing = profiles() + val sourceIndex = existing.indexOfFirst { it.id == id } + if (sourceIndex < 0) return null + val source = existing[sourceIndex] + val clone = source.copy( + id = UUID.randomUUID().toString(), + name = "${source.displayName} copy", + ) + writeProfiles(existing.toMutableList().apply { add(sourceIndex + 1, clone) }) + return clone + } + + @Synchronized + fun importProfiles(imported: List): List { + val existing = profiles() + val allocated = existing.toMutableList() + val added = imported.map { source -> + val profile = ProxyProfile( + id = UUID.randomUUID().toString(), + name = source.name, + colorIndex = ProfileColorMatcher.colorIndexForFlag( + source.countryCode, + nextColorIndex(allocated), + ), + countryCode = source.countryCode, + config = source.config, + ) + allocated += profile + profile + } + writeProfiles(existing + added) + return added + } + + @Synchronized + fun importConfiguration(configuration: PortableConfiguration): List { + val existing = profiles() + val idMap = mutableMapOf() + val added = configuration.profiles.map { source -> + val newId = UUID.randomUUID().toString() + idMap[source.id] = newId + source.copy(id = newId) + } + writeProfiles(existing + added) + val editor = prefs.edit() + .putInt(DIAGNOSTIC_LOG_LIMIT_MB, configuration.diagnosticLogLimitMb) + configuration.activeProfileId?.let(idMap::get)?.let { editor.putString(ACTIVE_PROFILE_ID, it) } + configuration.alwaysOnProfileId?.let(idMap::get)?.let { editor.putString(ALWAYS_ON_PROFILE_ID, it) } + editor.apply() + configuration.globalConnectionSettings?.let { importedSettings -> + saveGlobalConnectionSettings(importedSettings.copy( + failoverProfileIds = importedSettings.failoverProfileIds.mapNotNull(idMap::get), + )) + } + return added + } + + @Synchronized + fun saveProfile(profile: ProxyProfile) { + val current = profiles() + val updated = current.map { if (it.id == profile.id) profile else it } + if (updated != current) writeProfiles(updated) + } + + @Synchronized + fun trustSshHostKey(profileId: String, hop: String, fingerprint: String): Boolean { + if (!fingerprint.matches(Regex("SHA256:[A-Za-z0-9+/]{20,}={0,2}"))) return false + val current = profiles() + var changed = false + val updated = current.map { profile -> + if (profile.id != profileId) profile else { + changed = true + profile.copy(config = if (hop == "jump") profile.config.copy( + jumpTrustedHostKey = fingerprint, jumpAcceptAnyHostKey = false, + ) else profile.config.copy( + trustedHostKey = fingerprint, acceptAnyHostKey = false, + )) + } + } + if (changed) writeProfiles(updated) + return changed + } + + @Synchronized + fun deleteProfile(id: String): Boolean { + val current = profiles() + if (current.size <= 1 || current.none { it.id == id }) return false + val connectionWasDeleted = connectionProfile().id == id + val remaining = current.filterNot { it.id == id } + val replacement = remaining.first().id + val editor = prefs.edit().putString(PROFILES, encodeProfiles(remaining)) + if (activeProfileId() == id) editor.putString(ACTIVE_PROFILE_ID, replacement) + if (alwaysOnProfileId() == id) editor.putString(ALWAYS_ON_PROFILE_ID, replacement) + if (connectionWasDeleted) editor.putString(CONNECTION_PROFILE_ID, replacement) + editor.apply() + val settings = globalConnectionSettings() + if (id in settings.failoverProfileIds) { + saveGlobalConnectionSettings(settings.copy(failoverProfileIds = settings.failoverProfileIds - id)) + } + if (connectionWasDeleted && isFailoverActive()) setFailoverState(false, null) + return true + } + + /** Compatibility accessor for callers that operate on the selected profile. */ + fun load(): ProxyConfig = activeProfile().config + + /** Compatibility writer for per-profile settings screens. */ + fun save(config: ProxyConfig) = saveProfile(activeProfile().copy(config = config)) + + fun isConnectionDesired(): Boolean = prefs.getBoolean("connection_desired", false) + + fun setConnectionDesired(desired: Boolean) { + prefs.edit().putBoolean("connection_desired", desired).apply() + } + + fun diagnosticLogLimitMb(): Int = prefs.getInt(DIAGNOSTIC_LOG_LIMIT_MB, 3).coerceIn(1, 100) + + fun setDiagnosticLogLimitMb(value: Int) { + prefs.edit().putInt(DIAGNOSTIC_LOG_LIMIT_MB, value.coerceIn(1, 100)).apply() + } + + private fun ensureMigrated() { + if (prefs.contains(PROFILES)) return + synchronized(prefs) { + if (prefs.contains(PROFILES)) return + val profile = createInitialProfile() + prefs.edit() + .putString(PROFILES, encodeProfiles(listOf(profile))) + .putString(ACTIVE_PROFILE_ID, profile.id) + .putString(ALWAYS_ON_PROFILE_ID, profile.id) + .apply() + } + } + + private fun createInitialProfile(): ProxyProfile = ProxyProfile( + id = UUID.randomUUID().toString(), + colorIndex = 0, + config = legacyConfig(), + ) + + private fun legacyConfig() = ProxyConfig( + host = prefs.getString("host", "").orEmpty(), + port = prefs.getInt("port", 443), + username = prefs.getString("username", "").orEmpty(), + password = decrypt(prefs.getString("password", null)), + allowInvalidProxyCertificate = false, + profile = enumValue(prefs.getString("profile", null), TlsProfile.DEFAULT), + customJa3 = prefs.getString("custom_ja3", "").orEmpty(), + dnsProvider = enumValue(prefs.getString("dns_provider", null), DnsProvider.CLOUDFLARE), + customDohUrl = prefs.getString("custom_doh_url", "").orEmpty(), + selectedPackages = prefs.getStringSet("packages", emptySet())?.toSet().orEmpty(), + allowIpv6 = prefs.getBoolean("allow_ipv6", false), + routeAllApps = true, + bypassLocalNetworks = true, + ) + + private inline fun > enumValue(value: String?, default: T): T = + runCatching { enumValueOf(value ?: default.name) }.getOrDefault(default) + + private fun nextColorIndex(profiles: List): Int { + val counts = IntArray(ProfileColors.argb.size) + profiles.forEach { counts[Math.floorMod(it.colorIndex, counts.size)]++ } + return counts.indices.minWithOrNull(compareBy { counts[it] }.thenBy { it }) ?: 0 + } + + private fun writeProfiles(profiles: List) { + prefs.edit().putString(PROFILES, encodeProfiles(profiles)).apply() + } + + private fun encodeProfiles(profiles: List) = JSONArray().apply { + profiles.forEach { profile -> + put(JSONObject().apply { + put("id", profile.id) + put("name", profile.name.trim()) + put("color", profile.colorIndex) + put("countryCode", profile.countryCode.uppercase()) + put("config", encodeConfig(profile.config)) + }) + } + }.toString() + + private fun encodeConfig(config: ProxyConfig) = JSONObject().apply { + put("type", config.type.name) + put("host", config.host.trim()) + put("port", config.port) + put("username", config.username) + put("password", encrypt(config.password)) + put("privateKey", encrypt(config.privateKey)) + put("sshProfile", config.sshProfile.name) + put("trustedHostKey", config.trustedHostKey) + put("acceptAnyHostKey", config.acceptAnyHostKey) + put("jumpHost", config.jumpHost.trim()) + put("jumpPort", config.jumpPort) + put("jumpUsername", config.jumpUsername) + put("jumpPassword", encrypt(config.jumpPassword)) + put("jumpPrivateKey", encrypt(config.jumpPrivateKey)) + put("jumpTrustedHostKey", config.jumpTrustedHostKey) + put("jumpAcceptAnyHostKey", config.jumpAcceptAnyHostKey) + put("sameJumpAuthentication", config.sameJumpAuthentication) + put("allowInvalidProxyCertificate", config.allowInvalidProxyCertificate) + put("fingerprint", config.profile.name) + put("customJa3", config.customJa3.trim()) + put("dnsProvider", config.dnsProvider.name) + put("customDohUrl", config.customDohUrl.trim()) + put("packages", JSONArray(config.selectedPackages.sorted())) + put("allowIpv6", config.allowIpv6) + put("routeAllApps", config.routeAllApps) + put("bypassLocalNetworks", config.bypassLocalNetworks) + } + + private fun decodeProfiles(value: String?): List = runCatching { + val array = JSONArray(value ?: return emptyList()) + List(array.length()) { index -> + val item = array.getJSONObject(index) + ProxyProfile( + id = item.getString("id"), + name = item.optString("name"), + colorIndex = item.optInt("color", index), + countryCode = item.optString("countryCode"), + config = decodeConfig(item.getJSONObject("config")), + ) + } + }.getOrDefault(emptyList()) + + private fun decodeConfig(item: JSONObject) = ProxyConfig( + type = enumValue(item.optString("type"), ProxyType.HTTPS), + host = item.optString("host"), + port = item.optInt("port", 443), + username = item.optString("username"), + password = decrypt(item.optString("password").ifEmpty { null }), + privateKey = decrypt(item.optString("privateKey").ifEmpty { null }), + sshProfile = enumValue(item.optString("sshProfile"), SshProfile.DEFAULT), + trustedHostKey = item.optString("trustedHostKey"), + acceptAnyHostKey = item.optBoolean("acceptAnyHostKey", false), + jumpHost = item.optString("jumpHost"), + jumpPort = item.optInt("jumpPort", 22), + jumpUsername = item.optString("jumpUsername"), + jumpPassword = decrypt(item.optString("jumpPassword").ifEmpty { null }), + jumpPrivateKey = decrypt(item.optString("jumpPrivateKey").ifEmpty { null }), + jumpTrustedHostKey = item.optString("jumpTrustedHostKey"), + jumpAcceptAnyHostKey = item.optBoolean("jumpAcceptAnyHostKey", false), + sameJumpAuthentication = item.optBoolean("sameJumpAuthentication", true), + allowInvalidProxyCertificate = item.optBoolean("allowInvalidProxyCertificate", false), + profile = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT), + customJa3 = item.optString("customJa3"), + dnsProvider = enumValue(item.optString("dnsProvider"), DnsProvider.CLOUDFLARE), + customDohUrl = item.optString("customDohUrl"), + selectedPackages = item.optJSONArray("packages")?.let { array -> + (0 until array.length()).map { array.getString(it) }.toSet() + }.orEmpty(), + allowIpv6 = item.optBoolean("allowIpv6", false), + routeAllApps = item.optBoolean("routeAllApps", false), + bypassLocalNetworks = item.optBoolean("bypassLocalNetworks", true), + ) + + private fun encodeGlobalConnectionSettings(settings: GlobalConnectionSettings) = JSONObject().apply { + put("fingerprint", settings.tlsProfile.name) + put("sshProfile", settings.sshProfile.name) + put("sshAuthMode", settings.sshAuthMode.name) + put("sshKeepaliveSeconds", settings.sshKeepaliveSeconds) + put("sshMaxChannels", settings.sshMaxChannels) + put("sshRotationMinutes", settings.sshRotationMinutes) + put("sshRotationMb", settings.sshRotationMb) + put("failoverMode", settings.failoverMode.name) + put("failoverProfileIds", JSONArray(settings.failoverProfileIds)) + put("customJa3", settings.customJa3.trim()) + put("packages", JSONArray(settings.selectedPackages.sorted())) + put("routeAllApps", settings.routeAllApps) + put("bypassLocalNetworks", settings.bypassLocalNetworks) + }.toString() + + private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = runCatching { + val item = JSONObject(value) + val parsedTls = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT) + GlobalConnectionSettings( + tlsProfile = parsedTls.takeIf { it.available } ?: TlsProfile.DEFAULT, + sshProfile = enumValue(item.optString("sshProfile"), SshProfile.DEFAULT), + sshAuthMode = enumValue(item.optString("sshAuthMode"), SshAuthMode.AUTO), + sshKeepaliveSeconds = item.optInt("sshKeepaliveSeconds", 30).coerceIn(0, 3600), + sshMaxChannels = item.optInt("sshMaxChannels", 32).coerceIn(1, 256), + sshRotationMinutes = item.optInt("sshRotationMinutes", 0).coerceIn(0, 1440), + sshRotationMb = item.optInt("sshRotationMb", 0).coerceIn(0, 10240), + failoverMode = enumValue(item.optString("failoverMode"), FailoverMode.DISABLED), + failoverProfileIds = item.optJSONArray("failoverProfileIds")?.let { array -> + (0 until array.length()).mapNotNull { array.optString(it).takeIf(String::isNotBlank) } + }.orEmpty(), + customJa3 = item.optString("customJa3"), + selectedPackages = item.optJSONArray("packages")?.let { array -> + (0 until array.length()).mapNotNull { array.optString(it).takeIf(String::isNotBlank) }.toSet() + }.orEmpty(), + routeAllApps = item.optBoolean("routeAllApps", true), + bypassLocalNetworks = item.optBoolean("bypassLocalNetworks", true), + ) + }.getOrDefault(GlobalConnectionSettings()) + + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build()) + generateKey() + } + } + + private fun encrypt(plain: String): String { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + return Base64.encodeToString(cipher.iv + cipher.doFinal(plain.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) + } + + private fun decrypt(packed: String?): String = runCatching { + if (packed == null) return "" + val bytes = Base64.decode(packed, Base64.NO_WRAP) + require(bytes.size > IV_SIZE) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, IV_SIZE))) + cipher.doFinal(bytes.copyOfRange(IV_SIZE, bytes.size)).toString(Charsets.UTF_8) + }.getOrDefault("") + + private companion object { + const val KEY_ALIAS = "megaproxy.proxy.credentials.v1" + const val IV_SIZE = 12 + const val PROFILES = "profiles_v2" + const val ACTIVE_PROFILE_ID = "active_profile_id" + const val ALWAYS_ON_PROFILE_ID = "always_on_profile_id" + private const val FAILOVER_ACTIVE = "failover_active" + private const val FAILOVER_NOTICE = "failover_notice" + const val CONNECTION_PROFILE_ID = "connection_profile_id" + const val FLAG_COLOR_VERSION = "flag_color_version" + const val CURRENT_FLAG_COLOR_VERSION = 1 + const val DIAGNOSTIC_LOG_LIMIT_MB = "diagnostic_log_limit_mb" + const val GLOBAL_CONNECTION_SETTINGS = "global_connection_settings_v1" + private const val IPV6_PROFILE_MIGRATED = "ipv6_profile_migrated_v1" + private const val PENDING_RECONNECT = "pending_reconnect" + } +} diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt new file mode 100644 index 0000000..8b4540e --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -0,0 +1,258 @@ +package net.megaproxy487.data + +import net.megaproxy487.model.DnsProvider +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyProfile +import net.megaproxy487.model.ProxyType +import net.megaproxy487.model.SshProfile +import net.megaproxy487.model.SshAuthMode +import net.megaproxy487.model.FailoverMode +import net.megaproxy487.model.TlsProfile +import net.megaproxy487.model.GlobalConnectionSettings +import org.json.JSONArray +import org.json.JSONObject +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +enum class ConfigExportFormat(val extension: String, val mimeType: String) { + PROXY_LIST("txt", "text/plain"), + JSON("json", "application/json"), +} + +data class PortableConfiguration( + val profiles: List, + val activeProfileId: String?, + val alwaysOnProfileId: String?, + val diagnosticLogLimitMb: Int = 3, + val globalConnectionSettings: GlobalConnectionSettings? = null, + val skippedProfiles: Int = 0, +) + +object ConfigTransfer { + const val SCHEMA_VERSION = 6 + + fun exportProxyList(profiles: List, includePasswords: Boolean): String = + profiles.filter { it.config.type == ProxyType.HTTPS }.joinToString("\n", postfix = "\n") { profile -> + val config = profile.config + val password = if (includePasswords) config.password else "" + val userInfo = "${encode(config.username)}:${encode(password)}" + val query = buildList { + if (profile.name.isNotBlank()) add("title=${encode(profile.name.trim())}") + if (profile.countryCode.isNotBlank()) add("cc=${encode(profile.countryCode.uppercase())}") + }.joinToString("&") + buildString { + append("https://").append(userInfo).append('@').append(config.host.trim()) + if (config.port != 443) append(':').append(config.port) + if (query.isNotEmpty()) append('?').append(query) + } + } + + fun exportJson(store: ConfigStore, includePasswords: Boolean, includePrivateKeys: Boolean = false): String = JSONObject().apply { + put("schema", "dev.megaproxy.config") + put("version", SCHEMA_VERSION) + put("passwordsIncluded", includePasswords) + put("privateKeysIncluded", includePrivateKeys) + put("activeProfileId", store.activeProfileId()) + put("alwaysOnProfileId", store.alwaysOnProfileId()) + put("diagnosticLogLimitMb", store.diagnosticLogLimitMb()) + val global = store.globalConnectionSettings() + put("tls", JSONObject().apply { + put("fingerprint", global.tlsProfile.name) + put("customJa3", global.customJa3.trim()) + }) + put("ssh", JSONObject().apply { + put("fingerprint", global.sshProfile.name) + put("authMode", global.sshAuthMode.name) + put("keepaliveSeconds", global.sshKeepaliveSeconds) + put("maxChannels", global.sshMaxChannels) + put("rotationMinutes", global.sshRotationMinutes) + put("rotationMb", global.sshRotationMb) + }) + put("failover", JSONObject().apply { + put("mode", global.failoverMode.name) + put("profileIds", JSONArray(global.failoverProfileIds)) + }) + put("routing", JSONObject().apply { + put("routeAllApps", global.routeAllApps) + put("selectedPackages", JSONArray(global.selectedPackages.sorted())) + put("bypassLocalNetworks", global.bypassLocalNetworks) + }) + put("profiles", JSONArray().apply { + store.profiles().forEach { profile -> put(encodeProfile(profile, includePasswords, includePrivateKeys)) } + }) + }.toString(2) + + fun importJson(text: String): PortableConfiguration { + val root = JSONObject(text) + require(root.optString("schema") == "dev.megaproxy.config") { "This is not a MegaProxy configuration file" } + val version = root.optInt("version", 0) + require(version in 1..SCHEMA_VERSION) { "Unsupported MegaProxy configuration version: $version" } + val array = root.optJSONArray("profiles") ?: error("The configuration has no profiles") + require(array.length() <= MAX_IMPORTED_PROFILES) { "The configuration contains more than $MAX_IMPORTED_PROFILES profiles" } + val decoded = (0 until array.length()).map { index -> + runCatching { decodeProfile(array.getJSONObject(index), index) } + } + val decodedProfiles = decoded.mapNotNull(Result::getOrNull) + require(decodedProfiles.isNotEmpty()) { "The configuration contains no usable profiles" } + val legacyIpv6 = root.optJSONObject("routing")?.optBoolean("allowIpv6", false) ?: false + val profiles = if (version in 2..5) decodedProfiles.map { + it.copy(config = it.config.copy(allowIpv6 = legacyIpv6)) + } else decodedProfiles + return PortableConfiguration( + profiles = profiles, + activeProfileId = root.optString("activeProfileId").ifBlank { null }, + alwaysOnProfileId = root.optString("alwaysOnProfileId").ifBlank { null }, + diagnosticLogLimitMb = root.optInt("diagnosticLogLimitMb", 3).coerceIn(1, 100), + globalConnectionSettings = if (version >= 2) { + decodeGlobalSettings(root).let { global -> + if (version < 4) global.copy(sshProfile = profiles.first().config.sshProfile) else global + } + } else null, + skippedProfiles = decoded.count(Result::isFailure), + ) + } + + private fun decodeGlobalSettings(root: JSONObject): GlobalConnectionSettings { + val tls = root.optJSONObject("tls") ?: JSONObject() + val routing = root.optJSONObject("routing") ?: JSONObject() + val ssh = root.optJSONObject("ssh") ?: JSONObject() + val failover = root.optJSONObject("failover") ?: JSONObject() + val parsedTls = enumValue(tls.optString("fingerprint"), TlsProfile.DEFAULT) + val packages = routing.optJSONArray("selectedPackages")?.let { array -> + require(array.length() <= MAX_IMPORTED_PACKAGES) { "The configuration contains too many application package names" } + (0 until array.length()).mapNotNull { index -> + array.optString(index).takeIf { it.length <= 256 }?.trim() + ?.takeIf { it.matches(Regex("[A-Za-z0-9_.]+")) } + }.toSet() + }.orEmpty() + return GlobalConnectionSettings( + tlsProfile = parsedTls.takeIf { it.available } ?: TlsProfile.DEFAULT, + sshProfile = enumValue(ssh.optString("fingerprint"), SshProfile.DEFAULT), + sshAuthMode = enumValue(ssh.optString("authMode"), SshAuthMode.AUTO), + sshKeepaliveSeconds = ssh.optInt("keepaliveSeconds", 30).coerceIn(0, 3600), + sshMaxChannels = ssh.optInt("maxChannels", 32).coerceIn(1, 256), + sshRotationMinutes = ssh.optInt("rotationMinutes", 0).coerceIn(0, 1440), + sshRotationMb = ssh.optInt("rotationMb", 0).coerceIn(0, 10240), + failoverMode = enumValue(failover.optString("mode"), FailoverMode.DISABLED), + failoverProfileIds = failover.optJSONArray("profileIds")?.let { array -> + require(array.length() <= MAX_IMPORTED_PROFILES) { "The configuration contains too many failover profile IDs" } + (0 until array.length()).mapNotNull { + array.optString(it).takeIf { id -> id.isNotBlank() && id.length <= 256 } + } + }.orEmpty(), + customJa3 = tls.limitedString("customJa3", MAX_CUSTOM_JA3_LENGTH), + selectedPackages = packages, + routeAllApps = routing.optBoolean("routeAllApps", true), + bypassLocalNetworks = routing.optBoolean("bypassLocalNetworks", true), + ) + } + + private fun encodeProfile(profile: ProxyProfile, includePasswords: Boolean, includePrivateKeys: Boolean) = JSONObject().apply { + put("id", profile.id) + put("name", profile.name.trim()) + put("color", profile.colorIndex) + put("countryCode", profile.countryCode.uppercase()) + put("proxy", JSONObject().apply { + put("type", profile.config.type.name) + put("host", profile.config.host.trim()) + put("port", profile.config.port) + put("username", profile.config.username) + if (includePasswords) put("password", profile.config.password) + if (includePrivateKeys) put("privateKey", profile.config.privateKey) + put("allowInvalidProxyCertificate", profile.config.allowInvalidProxyCertificate) + put("sshProfile", profile.config.sshProfile.name) + put("trustedHostKey", profile.config.trustedHostKey) + put("acceptAnyHostKey", profile.config.acceptAnyHostKey) + if (profile.config.type == ProxyType.SSH_JUMP) put("jump", JSONObject().apply { + put("host", profile.config.jumpHost) + put("port", profile.config.jumpPort) + put("sameAuthentication", profile.config.sameJumpAuthentication) + if (!profile.config.sameJumpAuthentication) { + put("username", profile.config.jumpUsername) + if (includePasswords) put("password", profile.config.jumpPassword) + if (includePrivateKeys) put("privateKey", profile.config.jumpPrivateKey) + } + put("trustedHostKey", profile.config.jumpTrustedHostKey) + put("acceptAnyHostKey", profile.config.jumpAcceptAnyHostKey) + }) + }) + put("tls", JSONObject().apply { + put("fingerprint", profile.config.profile.name) + put("customJa3", profile.config.customJa3.trim()) + }) + put("dns", JSONObject().apply { + put("provider", profile.config.dnsProvider.name) + put("customDohUrl", profile.config.customDohUrl.trim()) + }) + put("routing", JSONObject().apply { + put("routeAllApps", profile.config.routeAllApps) + put("selectedPackages", JSONArray(profile.config.selectedPackages.sorted())) + put("allowIpv6", profile.config.allowIpv6) + put("bypassLocalNetworks", profile.config.bypassLocalNetworks) + }) + } + + private fun decodeProfile(item: JSONObject, index: Int): ProxyProfile { + val proxy = item.optJSONObject("proxy") ?: JSONObject() + val tls = item.optJSONObject("tls") ?: JSONObject() + val dns = item.optJSONObject("dns") ?: JSONObject() + val routing = item.optJSONObject("routing") ?: JSONObject() + val host = proxy.limitedString("host", 253).trim() + require(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) + val jump = proxy.optJSONObject("jump") + val packages = routing.optJSONArray("selectedPackages")?.let { array -> + require(array.length() <= MAX_IMPORTED_PACKAGES) { "A profile contains too many application package names" } + (0 until array.length()).mapNotNull { packageIndex -> + array.optString(packageIndex).takeIf { it.length <= 256 }?.trim() + ?.takeIf { it.matches(Regex("[A-Za-z0-9_.]+")) } + }.toSet() + }.orEmpty() + return ProxyProfile( + id = item.limitedString("id", 256).ifBlank { "import-$index" }, + name = item.limitedString("name", 256).trim(), + colorIndex = item.optInt("color", index), + countryCode = item.limitedString("countryCode", 2).uppercase() + .takeIf { it.matches(Regex("[A-Z]{2}")) }.orEmpty(), + config = ProxyConfig( + type = enumValue(proxy.optString("type"), ProxyType.HTTPS), + host = host, + port = proxy.optInt("port", 443).takeIf { it in 1..65535 } ?: 443, + username = proxy.limitedString("username", 4_096), + password = proxy.limitedString("password", 16_384), + privateKey = proxy.limitedString("privateKey", 64 * 1024), + sshProfile = enumValue(proxy.optString("sshProfile"), SshProfile.DEFAULT), + trustedHostKey = proxy.limitedString("trustedHostKey", 256), + acceptAnyHostKey = proxy.optBoolean("acceptAnyHostKey", false), + jumpHost = jump?.limitedString("host", 253).orEmpty(), + jumpPort = jump?.optInt("port", 22) ?: 22, + sameJumpAuthentication = jump?.optBoolean("sameAuthentication", true) ?: true, + jumpUsername = jump?.limitedString("username", 4_096).orEmpty(), + jumpPassword = jump?.limitedString("password", 16_384).orEmpty(), + jumpPrivateKey = jump?.limitedString("privateKey", 64 * 1024).orEmpty(), + jumpTrustedHostKey = jump?.limitedString("trustedHostKey", 256).orEmpty(), + jumpAcceptAnyHostKey = jump?.optBoolean("acceptAnyHostKey", false) ?: false, + allowInvalidProxyCertificate = proxy.optBoolean("allowInvalidProxyCertificate", false), + profile = enumValue(tls.optString("fingerprint"), TlsProfile.DEFAULT), + customJa3 = tls.limitedString("customJa3", MAX_CUSTOM_JA3_LENGTH), + dnsProvider = enumValue(dns.optString("provider"), DnsProvider.CLOUDFLARE), + customDohUrl = dns.limitedString("customDohUrl", 2_048), + selectedPackages = packages, + allowIpv6 = routing.optBoolean("allowIpv6", false), + routeAllApps = routing.optBoolean("routeAllApps", false), + bypassLocalNetworks = routing.optBoolean("bypassLocalNetworks", true), + ), + ) + } + + private fun JSONObject.limitedString(name: String, maxLength: Int): String = optString(name).also { + require(it.length <= maxLength) { "$name is too long" } + } + + private inline fun > enumValue(value: String, default: T): T = + enumValues().firstOrNull { it.name == value } ?: default + + private const val MAX_CUSTOM_JA3_LENGTH = 8 * 1024 + + private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.name()) + .replace("+", "%20") +} diff --git a/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt new file mode 100644 index 0000000..c1ef4a4 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt @@ -0,0 +1,67 @@ +package net.megaproxy487.data + +import net.megaproxy487.model.ProxyConfig +import org.json.JSONObject + +object FoxyProxyParser { + fun parse(text: String): Result = runCatching { + val root = JSONObject(text) + require(root.optString("schema") != "dev.megaproxy.config") { + "This is a MegaProxy configuration, not a FoxyProxy configuration" + } + val data = root.optJSONArray("data") + ?: error("The FoxyProxy configuration has no data array") + require(data.length() > 0) { "The FoxyProxy proxy list is empty" } + require(data.length() <= MAX_IMPORTED_PROFILES) { + "The FoxyProxy configuration contains more than $MAX_IMPORTED_PROFILES entries" + } + + var skippedNonHttps = 0 + val proxies = buildList { + for (index in 0 until data.length()) { + val item = data.optJSONObject(index) ?: continue + val type = item.optString("type").trim().lowercase() + if (type !in setOf("https", "ssl")) { + skippedNonHttps++ + continue + } + parseProxy(item, index + 1)?.let(::add) + } + } + require(proxies.isNotEmpty()) { + if (skippedNonHttps > 0) "The FoxyProxy configuration contains no HTTPS proxies" + else "The FoxyProxy configuration contains no usable proxies" + } + ProxyListImportResult(proxies, skippedNonHttps) + } + + private fun parseProxy(item: JSONObject, position: Int): ImportedProxy? { + val host = item.limitedString("hostname", 253).trim().ifEmpty { + item.limitedString("address", 253).trim() + } + require(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) { + "FoxyProxy entry $position has an invalid hostname" + } + val port = when (val value = item.opt("port")) { + is Number -> value.toInt() + is String -> value.toIntOrNull() + else -> null + }?.takeIf { it in 1..65535 } ?: 443 + val countryCode = item.optString("cc").trim().uppercase() + .takeIf { it.matches(Regex("[A-Z]{2}")) }.orEmpty() + return ImportedProxy( + name = item.limitedString("title", 256).trim(), + countryCode = countryCode, + config = ProxyConfig( + host = host, + port = port, + username = item.limitedString("username", 4_096), + password = item.limitedString("password", 16_384), + ), + ) + } + + private fun JSONObject.limitedString(name: String, maxLength: Int): String = optString(name).also { + require(it.length <= maxLength) { "$name is too long" } + } +} diff --git a/app/src/main/java/net/megaproxy487/data/LimitedInput.kt b/app/src/main/java/net/megaproxy487/data/LimitedInput.kt new file mode 100644 index 0000000..e279dbf --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/LimitedInput.kt @@ -0,0 +1,22 @@ +package net.megaproxy487.data + +import java.io.ByteArrayOutputStream +import java.io.InputStream + +const val MAX_CONFIG_FILE_BYTES = 4 * 1024 * 1024 +const val MAX_IMPORTED_PROFILES = 1_000 +const val MAX_IMPORTED_PACKAGES = 10_000 + +fun InputStream.readConfigText(): String { + val output = ByteArrayOutputStream(64 * 1024) + val buffer = ByteArray(16 * 1024) + var total = 0 + while (true) { + val count = read(buffer) + if (count < 0) break + total += count + require(total <= MAX_CONFIG_FILE_BYTES) { "Configuration file is larger than 4 MB" } + output.write(buffer, 0, count) + } + return output.toString(Charsets.UTF_8.name()) +} diff --git a/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt b/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt new file mode 100644 index 0000000..cee2182 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/PrivateKeyImport.kt @@ -0,0 +1,41 @@ +package net.megaproxy487.data + +import java.io.ByteArrayOutputStream +import java.io.InputStream + +const val MAX_PRIVATE_KEY_FILE_BYTES = 64 * 1024 + +private val supportedPrivateKeyTypes = setOf( + "OPENSSH PRIVATE KEY", + "PRIVATE KEY", + "RSA PRIVATE KEY", + "EC PRIVATE KEY", + "DSA PRIVATE KEY", +) + +fun InputStream.readPrivateKeyText(): String { + val output = ByteArrayOutputStream(8 * 1024) + val buffer = ByteArray(8 * 1024) + var total = 0 + while (true) { + val count = read(buffer) + if (count < 0) break + total += count + require(total <= MAX_PRIVATE_KEY_FILE_BYTES) { "Private key file is larger than 64 KiB" } + output.write(buffer, 0, count) + } + + val text = output.toString(Charsets.UTF_8.name()).removePrefix("\uFEFF").trim() + require(text.isNotEmpty()) { "Private key file is empty" } + require(!text.contains("BEGIN ENCRYPTED PRIVATE KEY") && + !text.contains(Regex("Proc-Type:\\s*4,ENCRYPTED", RegexOption.IGNORE_CASE))) { + "Passphrase-protected private keys are not supported" + } + + val begin = Regex("^-----BEGIN ([A-Z0-9 ]+)-----", RegexOption.MULTILINE).find(text) + ?: throw IllegalArgumentException("File does not contain a PEM or OpenSSH private key") + val type = begin.groupValues[1] + require(type in supportedPrivateKeyTypes) { "Unsupported private key format: $type" } + require(text.contains("-----END $type-----")) { "Private key footer is missing" } + return "$text\n" +} diff --git a/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt b/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt new file mode 100644 index 0000000..aaafa0b --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/ProxyListParser.kt @@ -0,0 +1,69 @@ +package net.megaproxy487.data + +import net.megaproxy487.model.ProxyConfig +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +data class ImportedProxy(val name: String, val countryCode: String, val config: ProxyConfig) +data class ProxyListImportResult(val proxies: List, val skippedNonHttps: Int) + +object ProxyListParser { + fun parse(text: String): Result = runCatching { + val lines = text.lineSequence().map(String::trim) + .filter { it.isNotEmpty() && !it.startsWith("#") } + .take(MAX_IMPORTED_PROFILES + 1) + .toList() + require(lines.isNotEmpty()) { "The proxy list is empty" } + require(lines.size <= MAX_IMPORTED_PROFILES) { "The proxy list contains more than $MAX_IMPORTED_PROFILES entries" } + var skippedNonHttps = 0 + val proxies = lines.mapIndexedNotNull { index, line -> + val uri = runCatching { URI(line) }.getOrElse { error("Line ${index + 1} is not a valid URI") } + if (!uri.scheme.equals("https", ignoreCase = true)) { + skippedNonHttps++ + null + } else { + parseLine(uri, index + 1) + } + } + ProxyListImportResult(proxies, skippedNonHttps) + } + + private fun parseLine(uri: URI, lineNumber: Int): ImportedProxy { + require(uri.toString().length <= 64 * 1024) { "Line $lineNumber is too long" } + val host = uri.host?.takeIf(String::isNotBlank) ?: error("Line $lineNumber has no proxy host") + val userInfo = uri.rawUserInfo ?: error("Line $lineNumber has no Basic Auth credentials") + val separator = userInfo.indexOf(':') + require(separator >= 0) { "Line $lineNumber has no Basic Auth password" } + val username = decodeUriComponent(userInfo.substring(0, separator)) + val password = decodeUriComponent(userInfo.substring(separator + 1)) + require(username.length <= 4_096) { "Line $lineNumber has an excessively long username" } + require(password.length <= 16_384) { "Line $lineNumber has an excessively long password" } + val query = parseQuery(uri.rawQuery) + return ImportedProxy( + name = query["title"].orEmpty(), + countryCode = query["cc"].orEmpty().uppercase() + .takeIf { it.matches(Regex("[A-Z]{2}")) }.orEmpty(), + config = ProxyConfig( + host = host, + port = if (uri.port == -1) 443 else uri.port, + username = username, + password = password, + ), + ) + } + + private fun parseQuery(rawQuery: String?): Map = rawQuery.orEmpty() + .splitToSequence('&').filter(String::isNotEmpty).take(101).toList().also { + require(it.size <= 100) { "A proxy URL contains too many query parameters" } + }.associate { pair -> + val separator = pair.indexOf('=') + if (separator < 0) decode(pair) to "" + else decode(pair.substring(0, separator)) to decode(pair.substring(separator + 1)) + } + + private fun decode(value: String): String = + URLDecoder.decode(value, StandardCharsets.UTF_8.name()) + + private fun decodeUriComponent(value: String): String = decode(value.replace("+", "%2B")) +} diff --git a/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt b/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt new file mode 100644 index 0000000..8d1a847 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/SuperProxyParser.kt @@ -0,0 +1,15 @@ +package net.megaproxy487.data + +object SuperProxyParser { + const val HEADER = "# superproxy:proxylist:v1" + + fun matches(text: String): Boolean = + text.lineSequence().map(String::trim).firstOrNull(String::isNotEmpty) == HEADER + + fun parse(text: String): Result = runCatching { + require(matches(text)) { "This is not a supported Super Proxy configuration file" } + // Super Proxy's optional `fingerprint` query parameter is a certificate pin. + // MegaProxy intentionally relies on Android's trust store and does not import pins. + ProxyListParser.parse(text).getOrThrow() + } +} diff --git a/app/src/main/java/net/megaproxy487/model/ProfileColorMatcher.kt b/app/src/main/java/net/megaproxy487/model/ProfileColorMatcher.kt new file mode 100644 index 0000000..9594b7d --- /dev/null +++ b/app/src/main/java/net/megaproxy487/model/ProfileColorMatcher.kt @@ -0,0 +1,50 @@ +package net.megaproxy487.model + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint + +object ProfileColorMatcher { + fun colorIndexForFlag(countryCode: String, fallback: Int): Int { + val profile = ProxyProfile(id = "color-sample", colorIndex = fallback, countryCode = countryCode) + val flag = profile.flagEmoji + if (flag.isEmpty()) return fallback + + val bitmap = Bitmap.createBitmap(BITMAP_SIZE, BITMAP_SIZE, Bitmap.Config.ARGB_8888) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = TEXT_SIZE + textAlign = Paint.Align.CENTER + } + Canvas(bitmap).drawText(flag, BITMAP_SIZE / 2f, BASELINE, paint) + + val votes = IntArray(ProfileColors.argb.size) + val pixels = IntArray(BITMAP_SIZE * BITMAP_SIZE) + bitmap.getPixels(pixels, 0, BITMAP_SIZE, 0, 0, BITMAP_SIZE, BITMAP_SIZE) + bitmap.recycle() + pixels.forEach { pixel -> + if (Color.alpha(pixel) < 96) return@forEach + val red = Color.red(pixel) + val green = Color.green(pixel) + val blue = Color.blue(pixel) + val max = maxOf(red, green, blue) + val min = minOf(red, green, blue) + if (max > 242 && min > 242) return@forEach + if (max - min < 24) return@forEach + val nearest = ProfileColors.argb.indices.minByOrNull { index -> + val candidate = ProfileColors.argb[index].toInt() + squared(red - Color.red(candidate)) + + squared(green - Color.green(candidate)) + + squared(blue - Color.blue(candidate)) + } ?: return@forEach + votes[nearest]++ + } + return votes.indices.maxByOrNull { votes[it] }?.takeIf { votes[it] > 0 } ?: fallback + } + + private fun squared(value: Int) = value * value + + private const val BITMAP_SIZE = 96 + private const val TEXT_SIZE = 72f + private const val BASELINE = 72f +} diff --git a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt new file mode 100644 index 0000000..f6c0a19 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt @@ -0,0 +1,196 @@ +package net.megaproxy487.model + +data class ProxyConfig( + val type: ProxyType = ProxyType.HTTPS, + val host: String = "", + val port: Int = 443, + val username: String = "", + val password: String = "", + val allowInvalidProxyCertificate: Boolean = false, + val profile: TlsProfile = TlsProfile.DEFAULT, + val customJa3: String = "", + val dnsProvider: DnsProvider = DnsProvider.CLOUDFLARE, + val customDohUrl: String = "", + val selectedPackages: Set = emptySet(), + val allowIpv6: Boolean = false, + val routeAllApps: Boolean = true, + val bypassLocalNetworks: Boolean = true, + val resolvedProxyIp: String = "", + val privateKey: String = "", + val sshProfile: SshProfile = SshProfile.DEFAULT, + val trustedHostKey: String = "", + val acceptAnyHostKey: Boolean = false, + val jumpHost: String = "", + val jumpPort: Int = 22, + val jumpUsername: String = "", + val jumpPassword: String = "", + val jumpPrivateKey: String = "", + val jumpTrustedHostKey: String = "", + val jumpAcceptAnyHostKey: Boolean = false, + val sameJumpAuthentication: Boolean = true, + val resolvedJumpIp: String = "", + val sshAuthMode: SshAuthMode = SshAuthMode.AUTO, + val sshKeepaliveSeconds: Int = 30, + val sshMaxChannels: Int = 32, + val sshRotationMinutes: Int = 0, + val sshRotationMb: Int = 0, +) { + fun connectionValidationError(): String? = when { + host.isBlank() -> if (type == ProxyType.HTTPS) "Enter the proxy hostname" else "Enter the SSH hostname" + host.contains(Regex("[/:\\s]")) -> "Enter a hostname without a scheme or path" + port !in 1..65535 -> "Port must be between 1 and 65535" + type == ProxyType.HTTPS && username.isBlank() -> "Enter the Basic Auth username" + type == ProxyType.HTTPS && password.isBlank() -> "Enter the Basic Auth password" + type != ProxyType.HTTPS && username.isBlank() -> "Enter the SSH username" + type == ProxyType.SSH_JUMP && jumpHost.isBlank() -> "Enter the jump host" + type == ProxyType.SSH_JUMP && jumpHost.contains(Regex("[/:\\s]")) -> "Enter a jump hostname without a scheme or path" + type == ProxyType.SSH_JUMP && jumpPort !in 1..65535 -> "Jump port must be between 1 and 65535" + type == ProxyType.SSH_JUMP && !sameJumpAuthentication && jumpUsername.isBlank() -> "Enter the jump SSH username" + type == ProxyType.HTTPS && profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null -> + "JA3 must contain five fields: version,ciphers,extensions,groups,points" + dnsProvider == DnsProvider.CUSTOM && !customDohUrl.matches(Regex("https://[^/\\s]+/.+")) -> + "The custom DoH URL must start with https://" + else -> null + } + + fun validationError(): String? = connectionValidationError() +} + +enum class ProxyType(val title: String, val defaultPort: Int) { + HTTPS("HTTPS", 443), + SSH("SSH", 22), + SSH_JUMP("SSH with Jump", 22), +} + +enum class SshProfile(val title: String) { + DEFAULT("Default (currently OpenSSH/Termux)"), + OPENSSH_TERMUX("OpenSSH / Termux"), + CONNECTBOT("ConnectBot"), + JUICESSH("JuiceSSH"), + TERMIUS_ANDROID("Termius Android"), +} + +enum class SshAuthMode(val title: String) { + AUTO("Auto: key, then password"), + PASSWORD_ONLY("Password only"), + KEY_ONLY("Private key only"), +} + +enum class FailoverMode(val title: String) { + DISABLED("Disabled"), + SELECTED("Selected profiles"), + ALL("All profiles"), +} + +data class ProxyProfile( + val id: String, + val name: String = "", + val colorIndex: Int, + val countryCode: String = "", + val config: ProxyConfig = ProxyConfig(), +) { + val displayName: String + get() = name.trim().ifEmpty { config.host.trim().ifEmpty { "New profile" } } + + val flagEmoji: String + get() { + val code = countryCode.uppercase() + if (!code.matches(Regex("[A-Z]{2}"))) return "" + return code.map { character -> + String(Character.toChars(0x1F1E6 + character.code - 'A'.code)) + }.joinToString("") + } + + val displayNameWithFlag: String + get() = listOf(flagEmoji, displayName).filter(String::isNotEmpty).joinToString(" ") +} + +object ProfileColors { + // Material Design 500 palette, excluding colors with poor contrast in light themes. + val argb = listOf( + 0xFFF44336, 0xFFE91E63, 0xFF9C27B0, 0xFF673AB7, + 0xFF3F51B5, 0xFF2196F3, 0xFF009688, 0xFF4CAF50, + 0xFF8BC34A, 0xFFFF9800, 0xFFFF5722, 0xFF795548, + ) +} + +enum class DnsProvider(val title: String, val url: String) { + CLOUDFLARE("Cloudflare", "https://cloudflare-dns.com/dns-query"), + GOOGLE("Google", "https://dns.google/dns-query"), + QUAD9("Quad9", "https://dns.quad9.net/dns-query"), + YANDEX("Yandex Basic", "https://common.dot.dns.yandex.net/dns-query"), + YANDEX_SAFE("Yandex Safe", "https://safe.dot.dns.yandex.net/dns-query"), + YANDEX_FAMILY("Yandex Family", "https://family.dot.dns.yandex.net/dns-query"), + CUSTOM("Custom DoH URL", ""), + + ; + + fun fallbackUrls(): List { + // Never bypass an explicitly selected content-filtering policy. + if (this == CUSTOM || this == YANDEX_SAFE || this == YANDEX_FAMILY) return emptyList() + return entries.asSequence() + .filter { it != this && it != CUSTOM && it != YANDEX_SAFE && it != YANDEX_FAMILY } + .map(DnsProvider::url) + .filter(String::isNotEmpty) + .toList() + } +} + +enum class TlsProfile(val title: String, val available: Boolean = true) { + DEFAULT("Default (currently Chrome Android)"), + CHROME_ANDROID("Chrome Android 133 (uTLS)"), + FIREFOX_ANDROID("Firefox Android 120 (uTLS)"), + EDGE_ANDROID("Edge Android (awaiting a verified profile)", false), + RANDOMIZED("Randomized compatible"), + SAMSUNG_INTERNET("Samsung Internet (awaiting a verified profile)", false), + YANDEX_BROWSER("Yandex Browser (awaiting a verified profile)", false), + CUSTOM("Manual JA3"), +} + +data class GlobalConnectionSettings( + val tlsProfile: TlsProfile = TlsProfile.DEFAULT, + val sshProfile: SshProfile = SshProfile.DEFAULT, + val sshAuthMode: SshAuthMode = SshAuthMode.AUTO, + val sshKeepaliveSeconds: Int = 30, + val sshMaxChannels: Int = 32, + val sshRotationMinutes: Int = 0, + val sshRotationMb: Int = 0, + val failoverMode: FailoverMode = FailoverMode.DISABLED, + val failoverProfileIds: List = emptyList(), + val customJa3: String = "", + val selectedPackages: Set = emptySet(), + val routeAllApps: Boolean = true, + val bypassLocalNetworks: Boolean = true, +) { + fun applyTo(config: ProxyConfig): ProxyConfig = config.copy( + profile = if (tlsProfile == TlsProfile.DEFAULT) TlsProfile.CHROME_ANDROID else tlsProfile, + customJa3 = customJa3, + sshProfile = sshProfile, + sshAuthMode = sshAuthMode, + sshKeepaliveSeconds = sshKeepaliveSeconds, + sshMaxChannels = sshMaxChannels, + sshRotationMinutes = sshRotationMinutes, + sshRotationMb = sshRotationMb, + selectedPackages = selectedPackages, + routeAllApps = routeAllApps, + bypassLocalNetworks = bypassLocalNetworks, + ) +} + +data class Ja3Spec( + val tlsVersion: Int, + val cipherSuites: List, + val extensions: List, + val supportedGroups: List, + val ecPointFormats: List, +) { + companion object { + fun parse(value: String): Ja3Spec? = runCatching { + val fields = value.trim().split(',') + require(fields.size == 5) + fun numbers(field: String): List = if (field.isBlank()) emptyList() else + field.split('-').map { it.toInt().also { number -> require(number in 0..65535) } } + Ja3Spec(fields[0].toInt(), numbers(fields[1]), numbers(fields[2]), numbers(fields[3]), numbers(fields[4])) + }.getOrNull() + } +} diff --git a/app/src/main/java/net/megaproxy487/ui/theme/MegaProxyTheme.kt b/app/src/main/java/net/megaproxy487/ui/theme/MegaProxyTheme.kt new file mode 100644 index 0000000..47cd8d1 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/ui/theme/MegaProxyTheme.kt @@ -0,0 +1,27 @@ +package net.megaproxy487.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun MegaProxyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && darkTheme -> dynamicDarkColorScheme(context) + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> dynamicLightColorScheme(context) + darkTheme -> darkColorScheme() + else -> lightColorScheme() + } + MaterialTheme(colorScheme = colorScheme, content = content) +} diff --git a/app/src/main/java/net/megaproxy487/vpn/AlwaysOnVpnStatus.kt b/app/src/main/java/net/megaproxy487/vpn/AlwaysOnVpnStatus.kt new file mode 100644 index 0000000..ecb8a81 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/AlwaysOnVpnStatus.kt @@ -0,0 +1,26 @@ +package net.megaproxy487.vpn + +import android.content.Context +import android.provider.Settings + +data class AlwaysOnVpnStatus( + val enabled: Boolean, + val lockdown: Boolean, + val configuredPackage: String?, +) + +val AlwaysOnVpnStatus.hasOtherProvider: Boolean + get() = configuredPackage != null && !enabled + +fun readAlwaysOnVpnStatus(context: Context): AlwaysOnVpnStatus { + val configuredPackage = runCatching { + Settings.Secure.getString(context.contentResolver, "always_on_vpn_app") + ?.takeIf(String::isNotBlank) + }.getOrNull() + val enabled = configuredPackage?.let { it == context.packageName } + ?: ProxyVpnService.isAlwaysOnMode + val lockdown = enabled && runCatching { + Settings.Secure.getInt(context.contentResolver, "always_on_vpn_lockdown", 0) == 1 + }.getOrDefault(ProxyVpnService.isLockdownMode) + return AlwaysOnVpnStatus(enabled, lockdown, configuredPackage) +} diff --git a/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt b/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt new file mode 100644 index 0000000..0f88bbd --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt @@ -0,0 +1,31 @@ +package net.megaproxy487.vpn + +enum class BlockingSignal { + TCP_TIMEOUT, + TLS_HANDSHAKE_TIMEOUT, + TLS_HANDSHAKE_RESET, + CONNECT_RESPONSE_TIMEOUT, + CONNECT_RESPONSE_RESET, + SSH_HANDSHAKE_TIMEOUT, + CONNECTION_RESET, + SILENT_DROP, +} + +object BlockingDetection { + fun classify(message: String): BlockingSignal? { + val value = message.lowercase() + if (listOf("authenticate", "credentials", "host_key", "certificate", "x509", "invalid ", "refused").any(value::contains)) return null + return when { + "stage=tls_handshake" in value && "reason=timeout" in value -> BlockingSignal.TLS_HANDSHAKE_TIMEOUT + "stage=tls_handshake" in value && ("reason=reset" in value || "reason=eof" in value) -> BlockingSignal.TLS_HANDSHAKE_RESET + "stage=connect_response" in value && "reason=timeout" in value -> BlockingSignal.CONNECT_RESPONSE_TIMEOUT + "stage=connect_response" in value && ("reason=reset" in value || "reason=eof" in value) -> BlockingSignal.CONNECT_RESPONSE_RESET + "ssh" in value && "handshake" in value && ("timeout" in value || "timed out" in value) -> BlockingSignal.SSH_HANDSHAKE_TIMEOUT + "handshake" in value && "timeout" in value -> BlockingSignal.TLS_HANDSHAKE_TIMEOUT + "reset" in value || "broken pipe" in value -> BlockingSignal.CONNECTION_RESET + "i/o timeout" in value || "timed out" in value -> BlockingSignal.TCP_TIMEOUT + "unexpected eof" in value || "silent" in value -> BlockingSignal.SILENT_DROP + else -> null + } + } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/BootstrapAddressCache.kt b/app/src/main/java/net/megaproxy487/vpn/BootstrapAddressCache.kt new file mode 100644 index 0000000..003fdfa --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/BootstrapAddressCache.kt @@ -0,0 +1,51 @@ +package net.megaproxy487.vpn + +import android.content.Context +import org.json.JSONObject +import java.security.MessageDigest + +class BootstrapAddressCache(context: Context) { + private val preferences = context.getSharedPreferences("bootstrap_address_cache", Context.MODE_PRIVATE) + + fun get(host: String, now: Long = System.currentTimeMillis()): String? { + val value = preferences.getString(key(host), null) ?: return null + return runCatching { + val json = JSONObject(value) + val address = json.getString("address") + val savedAt = json.getLong("savedAt") + address.takeIf { now - savedAt in 0..MAX_AGE_MS && isNumericIpv4(it) } + }.getOrNull() + } + + fun put(host: String, address: String, now: Long = System.currentTimeMillis()) { + if (!isNumericIpv4(address)) return + preferences.edit().putString( + key(host), + JSONObject().put("address", address).put("savedAt", now).toString(), + ).apply() + } + + fun remove(host: String) { + preferences.edit().remove(key(host)).apply() + } + + private fun key(host: String): String = MessageDigest.getInstance("SHA-256") + .digest(host.trim().lowercase().toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + // Bootstrap DoH deliberately requests an A record. Keeping this parser IPv4-only + // avoids both a compatibility dependency on API 29 and accidental DNS lookups. + private fun isNumericIpv4(address: String): Boolean { + val octets = address.split('.') + return octets.size == 4 && octets.all { octet -> + octet.isNotEmpty() && + octet.length <= 3 && + octet.all(Char::isDigit) && + octet.toIntOrNull() in 0..255 + } + } + + private companion object { + const val MAX_AGE_MS = 7L * 24 * 60 * 60 * 1_000 + } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt b/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt new file mode 100644 index 0000000..e463936 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt @@ -0,0 +1,75 @@ +package net.megaproxy487.vpn + +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.mutableStateListOf +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicInteger + +object DiagnosticLog { + fun add(message: String) { + // The persistent logger already has its own bounded background queue. Do not + // mirror normal VPN traffic into Compose state: native diagnostics can be very + // frequent and flooding the main looper makes the entire UI unresponsive. + PersistentDiagnosticLog.write(message) + } + + fun clear() = Unit +} + +enum class TestState { IDLE, RUNNING, SUCCEEDED, FAILED } + +object TestDiagnosticLog { + private const val MAX_ENTRIES = 300 + private val timeFormat = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") + private val mainHandler = Handler(Looper.getMainLooper()) + private val pendingUiUpdates = AtomicInteger(0) + val entries = mutableStateListOf() + private val mutableState = androidx.compose.runtime.mutableStateOf(TestState.IDLE) + private val mutableExitIp = androidx.compose.runtime.mutableStateOf(null) + val state: androidx.compose.runtime.State = mutableState + val exitIp: androidx.compose.runtime.State = mutableExitIp + + private fun onMain(action: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) action() + else if (pendingUiUpdates.incrementAndGet() <= MAX_PENDING_UI_UPDATES) { + mainHandler.post { + try { action() } finally { pendingUiUpdates.decrementAndGet() } + } + } else pendingUiUpdates.decrementAndGet() + } + + fun reset() = onMain { + entries.clear() + mutableExitIp.value = null + mutableState.value = TestState.IDLE + } + + fun begin() = onMain { + entries.clear() + mutableExitIp.value = null + mutableState.value = TestState.RUNNING + } + + fun add(message: String) { + val safeMessage = PrivacyLogSanitizer.sanitize(message) + PersistentDiagnosticLog.write("scope=connection_test $safeMessage") + onMain { + entries.add("${LocalTime.now().format(timeFormat)} $safeMessage") + while (entries.size > MAX_ENTRIES) entries.removeAt(0) + } + } + + fun succeed(ip: String) = onMain { + mutableExitIp.value = ip + mutableState.value = TestState.SUCCEEDED + } + + fun fail(message: String? = null) = onMain { + if (message != null) add(message) + mutableState.value = TestState.FAILED + } + + private const val MAX_PENDING_UI_UPDATES = 512 +} diff --git a/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt b/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt new file mode 100644 index 0000000..cf5bd6f --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt @@ -0,0 +1,204 @@ +package net.megaproxy487.vpn + +import android.content.Context +import android.os.Build +import java.io.File +import java.io.OutputStream +import java.io.RandomAccessFile +import java.time.Instant +import java.util.UUID +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +object PersistentDiagnosticLog { + const val DEFAULT_LIMIT_MB = 3 + const val MIN_LIMIT_MB = 1 + const val MAX_LIMIT_MB = 100 + + private const val CURRENT_FILE = "diagnostic.log" + private const val PREVIOUS_FILE = "diagnostic.1.log" + // Native networking can emit diagnostics faster than flash storage can append them. + // Keep memory bounded under an error storm; oldest pending diagnostics are expendable. + private val executor = ThreadPoolExecutor( + 1, 1, 0L, TimeUnit.MILLISECONDS, ArrayBlockingQueue(2_048), + { task -> Thread(task, "megaproxy-diagnostic-log").apply { isDaemon = true } }, + ThreadPoolExecutor.DiscardOldestPolicy(), + ) + private val lock = Any() + private val sessionId = UUID.randomUUID().toString().take(8) + @Volatile private var directory: File? = null + @Volatile private var limitMb = DEFAULT_LIMIT_MB + + fun initialize(context: Context, configuredLimitMb: Int) { + directory = File(context.filesDir, "logs").also { it.mkdirs() } + limitMb = configuredLimitMb.coerceIn(MIN_LIMIT_MB, MAX_LIMIT_MB) + enforceLimitAsync() + write("event=logger_initialized session=$sessionId api=${Build.VERSION.SDK_INT} limit_mb=$limitMb") + } + + fun setLimitMb(value: Int) { + limitMb = value.coerceIn(MIN_LIMIT_MB, MAX_LIMIT_MB) + enforceLimitAsync() + write("event=log_limit_changed limit_mb=$limitMb") + } + + fun write(rawMessage: String) { + val safeMessage = PrivacyLogSanitizer.sanitize(rawMessage) + val line = "${Instant.now()} session=$sessionId $safeMessage\n" + executor.execute { + synchronized(lock) { + val logDirectory = directory ?: return@synchronized + rotateIfNeeded(logDirectory, line.toByteArray().size.toLong()) + File(logDirectory, CURRENT_FILE).appendText(line, Charsets.UTF_8) + } + } + } + + /** Crash-path write: deliberately synchronous so it survives immediate process termination. */ + fun writeCrash(thread: Thread, throwable: Throwable) = synchronized(lock) { + val logDirectory = directory ?: return + val entry = buildString { + append("${Instant.now()} session=$sessionId event=uncaught_exception api=${Build.VERSION.SDK_INT}") + append(" thread=${PrivacyLogSanitizer.sanitize(thread.name)}\n") + var current: Throwable? = throwable + var causeDepth = 0 + while (current != null && causeDepth < 8) { + val prefix = if (causeDepth == 0) "exception" else "cause_$causeDepth" + append("$prefix=${safeCrashClass(current.javaClass.name)}") + append(" message=${PrivacyLogSanitizer.sanitize(current.message.orEmpty())}\n") + current.stackTrace.take(256).forEach { frame -> + append("at ${safeCrashClass(frame.className)}.${frame.methodName}") + append("(${frame.fileName ?: "unknown"}:${frame.lineNumber})\n") + } + current = current.cause + causeDepth++ + } + append("${Instant.now()} session=$sessionId event=crash_end\n") + } + rotateIfNeeded(logDirectory, entry.toByteArray(Charsets.UTF_8).size.toLong()) + File(logDirectory, CURRENT_FILE).appendText(entry, Charsets.UTF_8) + } + + fun readTail(maxBytes: Int): String = synchronized(lock) { + val logDirectory = directory ?: return "" + val files = listOf(PREVIOUS_FILE, CURRENT_FILE).map { File(logDirectory, it) }.filter(File::isFile) + var remaining = maxBytes.coerceAtLeast(1) + val chunks = ArrayDeque() + for (file in files.asReversed()) { + if (remaining == 0) break + val count = minOf(file.length(), remaining.toLong()).toInt() + if (count == 0) continue + val chunk = ByteArray(count) + RandomAccessFile(file, "r").use { input -> + input.seek(file.length() - count) + input.readFully(chunk) + } + chunks.addFirst(chunk) + remaining -= count + } + val bytes = chunks.fold(ByteArray(0)) { result, chunk -> result + chunk } + val text = bytes.toString(Charsets.UTF_8) + if (remaining == 0) text.substringAfter('\n', "") else text + } + + fun copyTo(output: OutputStream) { + // A bounded logger queue may discard diagnostics, but an export must never lose + // its task and then wait forever on a discarded Future. + synchronized(lock) { + val logDirectory = directory ?: return + listOf(PREVIOUS_FILE, CURRENT_FILE).forEach { name -> + val file = File(logDirectory, name) + if (file.isFile) file.inputStream().buffered().use { it.copyTo(output) } + } + } + } + + fun clear() { + executor.execute { + synchronized(lock) { + val logDirectory = directory ?: return@synchronized + File(logDirectory, CURRENT_FILE).delete() + File(logDirectory, PREVIOUS_FILE).delete() + } + write("event=log_cleared") + } + } + + private fun rotateIfNeeded(logDirectory: File, incomingBytes: Long) { + val segmentLimit = segmentLimitBytes() + val current = File(logDirectory, CURRENT_FILE) + if (current.length() + incomingBytes <= segmentLimit) return + val previous = File(logDirectory, PREVIOUS_FILE) + previous.delete() + if (current.isFile) current.renameTo(previous) + if (incomingBytes > segmentLimit) current.writeText("", Charsets.UTF_8) + } + + private fun enforceLimitAsync() { + executor.execute { + synchronized(lock) { + val logDirectory = directory ?: return@synchronized + val segmentLimit = segmentLimitBytes() + trimToTail(File(logDirectory, PREVIOUS_FILE), segmentLimit) + trimToTail(File(logDirectory, CURRENT_FILE), segmentLimit) + } + } + } + + private fun segmentLimitBytes(): Long = limitMb.toLong() * 1024 * 1024 / 2 + + private fun trimToTail(file: File, maxBytes: Long) { + if (!file.isFile || file.length() <= maxBytes) return + val temporary = File(file.parentFile, "${file.name}.trim") + RandomAccessFile(file, "r").use { input -> + input.seek(file.length() - maxBytes) + while (input.filePointer < input.length() && input.read() != '\n'.code) Unit + temporary.outputStream().buffered().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + } + } + } + file.delete() + temporary.renameTo(file) + } + + private fun safeCrashClass(name: String): String = when { + name.startsWith("net.megaproxy487.") || + name.startsWith("android.") || name.startsWith("androidx.") || + name.startsWith("java.") || name.startsWith("kotlin.") || + name.startsWith("kotlinx.") -> name + else -> "[external]" + } +} + +object PrivacyLogSanitizer { + private val email = Regex("(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}") + private val url = Regex("(?i)https?://[^\\s]+") + private val ipv4 = Regex("(? Unit): String? + fun start(tunFd: Int, config: ProxyConfig, status: (String) -> Unit): Boolean + fun test(config: ProxyConfig, status: (String) -> Unit): String? + fun stop() +} + +data class NativeConnectionStats( + val downloadBytes: Long, + val uploadBytes: Long, + val proxyLatencyMillis: Double, + val proxyLatencyAtMillis: Long, + val connectionErrorRate: Double, + val connectionSamples: Int, + val totalOutcomes: Long, +) + +object ConnectionStatsReader { + fun snapshot(): NativeConnectionStats? = runCatching { + val raw = Class.forName("mobile.Mobile").getMethod("getStats").invoke(null) as String + val json = JSONObject(raw) + NativeConnectionStats( + downloadBytes = json.getLong("downloadBytes"), + uploadBytes = json.getLong("uploadBytes"), + proxyLatencyMillis = json.getDouble("proxyLatencyMillis"), + proxyLatencyAtMillis = json.getLong("proxyLatencyAtMillis"), + connectionErrorRate = json.getDouble("connectionErrorRate"), + connectionSamples = json.getInt("connectionSamples"), + totalOutcomes = json.getLong("totalOutcomes"), + ) + }.getOrNull() +} + +/** JNI boundary for the Go/uTLS userspace TCP/IP stack. */ +class NativeProxyCore( + private val vpnService: VpnService, + private val diagnostics: (String) -> Unit = DiagnosticLog::add, +) : ProxyCore { + private fun protectSocket(fd: Long): Boolean = vpnService.protect(fd.toInt()) + + private fun configJson(config: ProxyConfig) = JSONObject() + .put("type", config.type.name) + .put("host", config.host.trim()) + .put("dialHost", config.resolvedProxyIp) + .put("port", config.port) + .put("username", config.username) + .put("password", config.password) + .put("privateKey", config.privateKey) + .put("sshProfile", config.sshProfile.name) + .put("trustedHostKey", config.trustedHostKey) + .put("acceptAnyHostKey", config.acceptAnyHostKey) + .put("jumpHost", config.jumpHost.trim()) + .put("jumpDialHost", config.resolvedJumpIp) + .put("jumpPort", config.jumpPort) + .put("jumpUsername", config.jumpUsername) + .put("jumpPassword", config.jumpPassword) + .put("jumpPrivateKey", config.jumpPrivateKey) + .put("jumpTrustedHostKey", config.jumpTrustedHostKey) + .put("jumpAcceptAnyHostKey", config.jumpAcceptAnyHostKey) + .put("sameJumpAuthentication", config.sameJumpAuthentication) + .put("sshAuthMode", config.sshAuthMode.name) + .put("sshKeepaliveSeconds", config.sshKeepaliveSeconds) + .put("sshMaxChannels", config.sshMaxChannels) + .put("sshRotationMinutes", config.sshRotationMinutes) + .put("sshRotationMb", config.sshRotationMb) + .put("allowInvalidProxyCertificate", config.allowInvalidProxyCertificate) + .put("profile", config.profile.name) + .put("customJa3", config.customJa3.trim()) + .put("dohUrl", if (config.dnsProvider.url.isNotEmpty()) config.dnsProvider.url else config.customDohUrl.trim()) + .put("dohFallbackUrls", JSONArray(config.dnsProvider.fallbackUrls())) + .put("allowIpv6", config.allowIpv6) + .put("bypassLocalNetworks", config.bypassLocalNetworks) + .toString() + + private fun callback(type: Class<*>, methodName: String, callback: (Array?) -> Any?) = + Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, args -> + if (method.name == methodName) callback(args) else error("Unknown native callback ${method.name}") + } + + override fun resolveProxy(host: String, status: (String) -> Unit): String? = runCatching { + val mobile = Class.forName("mobile.Mobile") + val protectorType = Class.forName("mobile.Protector") + val reporterType = Class.forName("mobile.Reporter") + val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) } + val reporter = callback(reporterType, "report") { diagnostics(it!![0] as String); null } + mobile.getMethod("resolveProxy", String::class.java, protectorType, reporterType) + .invoke(null, host, protector, reporter) as String + }.onFailure { + val message = it.cause?.message ?: it.message ?: "Unknown native error" + diagnostics("event=bootstrap_dns result=failed detail=$message") + status("Proxy DNS failed: $message") + }.getOrNull() + + override fun start(tunFd: Int, config: ProxyConfig, status: (String) -> Unit): Boolean { + var detachedFd: Int? = null + return runCatching { + val mobile = Class.forName("mobile.Mobile") + val protectorType = Class.forName("mobile.Protector") + val reporterType = Class.forName("mobile.Reporter") + val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) } + val reporter = callback(reporterType, "report") { + val message = it!![0] as String + diagnostics(message) + VpnRuntimeState.observeDiagnostic(message) + if ("SSH_HOST_KEY_" in message || "dpi_hint=possible" in message) status(message) + null + } + detachedFd = ParcelFileDescriptor.fromFd(tunFd).detachFd() + val startMethod = mobile.getMethod( + "start", Long::class.javaPrimitiveType, String::class.java, protectorType, reporterType, + ) + val goFd = detachedFd!! + detachedFd = null // Start's contract takes ownership, including error paths. + startMethod.invoke(null, goFd.toLong(), configJson(config), protector, reporter) + status("TCP is protected by ${config.type.title}") + true + }.getOrElse { + detachedFd?.let { fd -> runCatching { ParcelFileDescriptor.adoptFd(fd).close() } } + status(if (it is ClassNotFoundException) "Add app/libs/megaproxy.aar" else "Native core error: ${it.cause?.message ?: it.message}") + false + } + } + + override fun test(config: ProxyConfig, status: (String) -> Unit): String? = runCatching { + val mobile = Class.forName("mobile.Mobile") + val protectorType = Class.forName("mobile.Protector") + val reporterType = Class.forName("mobile.Reporter") + val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) } + val reporter = callback(reporterType, "report") { + val message = it!![0] as String + diagnostics(message) + if ("SSH_HOST_KEY_" in message) status(message) + null + } + mobile.getMethod("testConnection", String::class.java, protectorType, reporterType) + .invoke(null, configJson(config), protector, reporter) as String + }.onSuccess { + status("Test passed: exit IP $it") + }.onFailure { + val message = it.cause?.message ?: it.message ?: "Unknown native error" + diagnostics("event=connection_test result=failed detail=$message") + status("Test failed: $message") + }.getOrNull() + + override fun stop() { + runCatching { Class.forName("mobile.Mobile").getMethod("stop").invoke(null) } + } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt new file mode 100644 index 0000000..4d6c4c0 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -0,0 +1,861 @@ +package net.megaproxy487.vpn + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Network +import android.net.NetworkRequest +import android.os.Build +import android.os.ParcelFileDescriptor +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import net.megaproxy487.MainActivity +import net.megaproxy487.SshHostKeyActivity +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.model.FailoverMode +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.ConcurrentHashMap +import kotlin.concurrent.thread +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class ProxyVpnService : VpnService() { + @Volatile private var tunnel: ParcelFileDescriptor? = null + @Volatile private var core: ProxyCore? = null + @Volatile private var activeConfig: net.megaproxy487.model.ProxyConfig? = null + @Volatile private var tunnelTestOnly = false + @Volatile private var hostKeyPrompt: PendingIntent? = null + @Volatile private var failoverNotice: String? = null + @Volatile private var connectionBlockedForAction = false + @Volatile private var reconnectAfterStart = false + @Volatile private var healthWarningActive = false + @Volatile private var consecutiveStartFailures = 0 + @Volatile private var nextStartAttemptAt = 0L + @Volatile private var retryStatus: String? = null + private var lastHealthBytes = 0L + private var lastHealthOutcomes = 0L + private val tunnelStateLock = Any() + private val startGeneration = AtomicLong(0) + private val probableFailureCounts = ConcurrentHashMap() + private val probableFailureTimes = ConcurrentHashMap() + private val attemptedFailoverProfiles = ConcurrentHashMap.newKeySet() + private val freshDnsRecoveryProfiles = ConcurrentHashMap.newKeySet() + private val underlyingCapabilities = ConcurrentHashMap() + @Volatile private var underlyingNetwork: Network? = null + @Volatile private var networkCallbackRegistered = false + private val monitorHandler = Handler(Looper.getMainLooper()) + private val reconnectForNetworkChange = Runnable { + if (ConfigStore(this).isConnectionDesired() && tunnel != null && !testRunning.get()) { + DiagnosticLog.add("event=network_handover action=reconnect") + startService(Intent(this, ProxyVpnService::class.java).setAction(ACTION_RECONNECT) + .putExtra(EXTRA_RECONNECT_REASON, "underlying_network_changed")) + } + } + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + reconsiderUnderlyingNetworks(network) + } + + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + underlyingCapabilities[network] = capabilities + reconsiderUnderlyingNetworks(network) + } + + override fun onLost(network: Network) { + underlyingCapabilities.remove(network) + reconsiderUnderlyingNetworks() + } + } + private val monitor = object : Runnable { + override fun run() { + val desired = ConfigStore(this@ProxyVpnService).isConnectionDesired() + if (desired) { + updatePassiveHealthState() + getSystemService(NotificationManager::class.java).notify( + NOTIFICATION_ID, + notification(failoverNotice ?: retryStatus ?: VpnRuntimeState.networkWarning.value ?: if (isRunning) "Connected" else "Reconnecting…"), + ) + val retryDue = SystemClock.elapsedRealtime() >= nextStartAttemptAt + if (retryDue && tunnel == null && hostKeyPrompt == null && !connectionBlockedForAction && !testRunning.get() && startRunning.compareAndSet(false, true)) { + val generation = startGeneration.get() + VpnRuntimeState.update(VpnConnectionState.CONNECTING) + thread(name = "megaproxy-vpn-recovery") { + try { + startTunnel(testOnly = false, generation = generation) + } catch (error: Exception) { + if (!isStartCurrent(generation)) return@thread + handleStartFailure(false, "VPN recovery failed", error.message.orEmpty(), ConfigStore(this@ProxyVpnService).connectionProfile().id) + } finally { + finishStartAttempt() + } + } + } + } + monitorHandler.postDelayed(this, MONITOR_INTERVAL_MS) + } + } + + override fun onCreate() { + super.onCreate() + failoverNotice = ConfigStore(this).failoverNotice() + failoverNotice?.let(VpnRuntimeState::updateNetworkWarning) + createChannel() + registerUnderlyingNetworkCallback() + monitorHandler.post(monitor) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val systemAlwaysOnStart = intent?.action == SERVICE_INTERFACE + // Android starts the selected service with SERVICE_INTERFACE on older releases; + // the explicit isAlwaysOn property itself was only added in API 29. + val platformAlwaysOn = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isAlwaysOn + // ACTION_REFRESH_STATUS must be allowed to clear a stale true value after the + // user disables Always-on in Android Settings. + isAlwaysOnMode = platformAlwaysOn || systemAlwaysOnStart + isLockdownMode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) isLockdownEnabled else false + val store = ConfigStore(this) + // Android may call the sticky service again with a null intent. Do not replace a + // failover profile on every callback; only a fresh system Always-on start selects + // the configured base profile. + if (isAlwaysOnMode && intent?.action != ACTION_START_MANUAL) { + store.setConnectionDesired(true) + if (intent?.action == SERVICE_INTERFACE && !store.isFailoverActive()) { + store.setConnectionProfile(store.alwaysOnProfileId()) + store.setFailoverState(false, null) + failoverNotice = null + VpnRuntimeState.updateNetworkWarning(null) + } + } + if (intent?.action == ACTION_START_MANUAL) { + probableFailureCounts.clear(); probableFailureTimes.clear(); attemptedFailoverProfiles.clear(); failoverNotice = null + freshDnsRecoveryProfiles.clear() + connectionBlockedForAction = false + resetRetryState() + store.setFailoverState(false, null) + VpnRuntimeState.updateNetworkWarning(null) + } + VpnRuntimeState.updateSystem(isAlwaysOnMode, isLockdownMode, store.connectionProfileId()) + if (intent?.action == ACTION_REFRESH_STATUS) { + return if (tunnel != null) START_STICKY else START_NOT_STICKY + } + if (intent?.action == ACTION_DISMISS_HOST_KEY) { + hostKeyPrompt = null + if (tunnel == null) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() } + return START_NOT_STICKY + } + if (intent?.action == ACTION_STOP) { + stopTunnel() + stopSelf() + return START_NOT_STICKY + } + if (intent?.action == ACTION_RECONNECT) { + hostKeyPrompt = null; failoverNotice = null + connectionBlockedForAction = false + resetRetryState() + store.setFailoverState(false, null) + probableFailureCounts.clear(); probableFailureTimes.clear(); attemptedFailoverProfiles.clear() + VpnRuntimeState.updateNetworkWarning(null) + DiagnosticLog.add("event=vpn_reconnect reason=${intent.getStringExtra(EXTRA_RECONNECT_REASON) ?: "settings_changed"}") + startForeground(NOTIFICATION_ID, notification("Reconnecting…")) + stopTunnel(removeForeground = false) + val generation = startGeneration.get() + ConfigStore(this).setConnectionDesired(true) + if (startRunning.compareAndSet(false, true)) { + VpnRuntimeState.update(VpnConnectionState.CONNECTING) + thread(name = "megaproxy-vpn-reconnect") { + try { + startTunnel(testOnly = false, generation = generation) + } catch (error: Exception) { + if (!isStartCurrent(generation)) return@thread + handleStartFailure(false, "VPN reconnect failed", error.message.orEmpty(), ConfigStore(this@ProxyVpnService).connectionProfile().id) + } finally { + finishStartAttempt() + } + } + } else reconnectAfterStart = true + return START_STICKY + } + if (intent?.action == ACTION_TEST) { + if (startRunning.get()) { + TestDiagnosticLog.fail("Wait for the current VPN connection attempt to finish") + return START_NOT_STICKY + } + if (!testRunning.compareAndSet(false, true)) { + TestDiagnosticLog.add("Connection test is already running") + return START_NOT_STICKY + } + hostKeyPrompt = null + SshHostKeyPromptState.clear() + startForeground(NOTIFICATION_ID, notification("Testing connection…")) + TestDiagnosticLog.begin() + thread(name = "megaproxy-connection-test") { + try { + testConnection() + } catch (error: Exception) { + TestDiagnosticLog.fail("Connection test stopped unexpectedly: ${error.message ?: error.javaClass.simpleName}") + if (tunnelTestOnly) stopTunnel() + } finally { + testRunning.set(false) + } + } + return START_NOT_STICKY + } + startForeground(NOTIFICATION_ID, notification("Connecting…")) + if (tunnel == null && startRunning.compareAndSet(false, true)) { + val generation = startGeneration.get() + VpnRuntimeState.update(VpnConnectionState.CONNECTING) + thread(name = "megaproxy-vpn-start") { + try { + startTunnel(testOnly = false, generation = generation) + } catch (error: Exception) { + if (!isStartCurrent(generation)) return@thread + handleStartFailure(false, "VPN start failed", error.message.orEmpty(), ConfigStore(this@ProxyVpnService).connectionProfile().id) + } finally { + finishStartAttempt() + } + } + } + return START_STICKY + } + + private fun finishStartAttempt() { + startRunning.set(false) + if (reconnectAfterStart) { + reconnectAfterStart = false + monitorHandler.post { + startService(Intent(this, ProxyVpnService::class.java).setAction(ACTION_RECONNECT) + .putExtra(EXTRA_RECONNECT_REASON, "profile_changed_during_connect")) + } + } + } + + private fun testConnection() { + val storedConfig = ConfigStore(this).globalConnectionSettings().applyTo(ConfigStore(this).activeProfile().config) + storedConfig.connectionValidationError()?.let { + TestDiagnosticLog.fail("Connection test cannot start: $it") + if (tunnel == null) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() } + return + } + val temporaryVpn = tunnel == null + if (temporaryVpn && !startTunnel(testOnly = true, suppliedConfig = storedConfig, generation = startGeneration.get())) { + TestDiagnosticLog.fail("Connection test failed: temporary VPN could not be started") + if (hostKeyPrompt == null) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + return + } + val config = synchronized(tunnelStateLock) { activeConfig } ?: run { + TestDiagnosticLog.fail("Connection test failed: active VPN configuration is unavailable") + return + } + val exitIp = NativeProxyCore(this, TestDiagnosticLog::add).test(config) { message -> + configureHostKeyPrompt(message, ConfigStore(this).activeProfileId(), true) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message)) + } + if (exitIp != null) TestDiagnosticLog.succeed(exitIp) else TestDiagnosticLog.fail() + if (temporaryVpn) { + stopTunnel() + stopSelf() + } + } + + private fun startTunnel( + testOnly: Boolean, + suppliedConfig: net.megaproxy487.model.ProxyConfig? = null, + generation: Long = startGeneration.get(), + ): Boolean { + val configStore = ConfigStore(this) + val pendingReconnectToken = if (testOnly) null else configStore.pendingReconnectToken() + val storedProfile = configStore.connectionProfile() + val storedConfig = suppliedConfig ?: configStore.globalConnectionSettings().applyTo(storedProfile.config) + val promptProfileId = if (testOnly) configStore.activeProfileId() else storedProfile.id + val diagnostics = if (testOnly) TestDiagnosticLog::add else DiagnosticLog::add + var failureDetail = "" + diagnostics( + if (testOnly) "event=vpn_start mode=test" + else if (storedConfig.routeAllApps) "event=vpn_start mode=global" + else "event=vpn_start mode=split selected_app_count=${storedConfig.selectedPackages.size}" + ) + val validationError = if (testOnly) storedConfig.connectionValidationError() else storedConfig.validationError() + validationError?.let { + if (testOnly) TestDiagnosticLog.fail(it) else { + val notice = if (isAlwaysOnMode) + "VPN configuration requires attention. Fix it in MegaProxy, then reconnect from Android Always-on VPN settings." + else "VPN configuration requires attention. Open MegaProxy settings." + VpnRuntimeState.updateNetworkWarning(notice) + if (isAlwaysOnMode) { + connectionBlockedForAction = true + startForeground(NOTIFICATION_ID, notification(notice)) + VpnRuntimeState.update(VpnConnectionState.CONNECTING) + } else { + ConfigStore(this).setConnectionDesired(false) + VpnRuntimeState.update(VpnConnectionState.DISCONNECTED) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return false + } + val builder = Builder() + .setSession("MegaProxy") + // Leave headroom for cellular, hotspot and nested-tunnel encapsulation. + // 1400 avoids common PMTU black holes while remaining efficient for TCP. + .setMtu(VPN_MTU) + .addAddress("10.77.0.1", 30) + .addAddress("fd77::1", 126) + .addRoute("0.0.0.0", 0) + .addRoute("::", 0) + .addDnsServer("10.77.0.2") + .setBlocking(true) + if (testOnly) { + builder.addAllowedApplication(packageName) + } else if (!storedConfig.routeAllApps) { + var allowedCount = 0 + storedConfig.selectedPackages.forEach { selectedPackage -> + runCatching { builder.addAllowedApplication(selectedPackage) } + .onSuccess { allowedCount++ } + .onFailure { diagnostics("event=routing package=unavailable action=skipped") } + } + if (allowedCount == 0) { + // An empty allow-list means "all apps" to VpnService.Builder. Route only + // our own process so an empty/missing split list cannot become global VPN. + builder.addAllowedApplication(packageName) + diagnostics("event=routing mode=split selected_app_count=0 fallback=self_only") + } + } + val establishedTunnel = builder.establish() ?: run { + handleStartFailure(testOnly, "VPN interface could not be established") + return false + } + diagnostics("TUN established with IPv4, IPv6 and intercepted DNS") + val proxyCore = NativeProxyCore(this, diagnostics) + val addressCache = BootstrapAddressCache(this) + fun resolveHost(host: String, target: String): String? { + if (!testOnly) { + addressCache.get(host)?.let { + diagnostics("event=bootstrap_dns result=cache_hit target=$target age_limit_days=7") + return it + } + } + return proxyCore.resolveProxy(host) { message -> + failureDetail = message + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message)) + }?.also { addressCache.put(host, it) } + } + val proxyIp = resolveHost(storedConfig.host, "proxy") ?: run { + establishedTunnel.close() + if (!isStartCurrent(generation)) return false + handleStartFailure(testOnly, "Proxy bootstrap DNS failed", failureDetail, promptProfileId) + return false + } + val jumpIp = if (storedConfig.type == net.megaproxy487.model.ProxyType.SSH_JUMP) { + resolveHost(storedConfig.jumpHost, "jump") ?: run { + establishedTunnel.close() + if (!isStartCurrent(generation)) return false + handleStartFailure(testOnly, "Jump host bootstrap DNS failed", failureDetail, promptProfileId) + return false + } + } else "" + if (!isStartCurrent(generation)) { + establishedTunnel.close() + return false + } + val config = storedConfig.copy(resolvedProxyIp = proxyIp, resolvedJumpIp = jumpIp) + val started = proxyCore.start(establishedTunnel.fd, config) { message -> + failureDetail = message + configureHostKeyPrompt(message, promptProfileId, testOnly) + if (!testOnly && "dpi_hint=possible" in message) monitorHandler.post { handleRuntimeDiagnostic(promptProfileId, message) } + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message)) + } + if (!started) { + establishedTunnel.close() + if (isStartCurrent(generation)) { + isRunning = false + handleStartFailure(testOnly, "Native proxy core failed to start", failureDetail, promptProfileId) + } + return false + } + val committed = synchronized(tunnelStateLock) { + if (!isStartCurrent(generation) || tunnel != null) false else { + tunnel = establishedTunnel + core = proxyCore + tunnelTestOnly = testOnly + activeConfig = config + isRunning = true + true + } + } + if (!committed) { + proxyCore.stop() + establishedTunnel.close() + return false + } + underlyingNetwork?.let { setUnderlyingNetworks(arrayOf(it)) } + probableFailureCounts.remove(promptProfileId) + probableFailureTimes.remove(promptProfileId) + if (failoverNotice == null) VpnRuntimeState.updateNetworkWarning(null) + else VpnRuntimeState.updateNetworkWarning(failoverNotice) + VpnRuntimeState.updateSystem(isAlwaysOnMode, isLockdownMode, promptProfileId) + if (!testOnly) configStore.clearPendingReconnect(pendingReconnectToken) + resetRetryState() + VpnRuntimeState.update(VpnConnectionState.CONNECTED) + return true + } + + private fun isStartCurrent(generation: Long): Boolean = startGeneration.get() == generation + + private fun handleStartFailure(testOnly: Boolean, message: String, detail: String = message, profileId: String = "") { + if (testOnly) { + TestDiagnosticLog.fail(message) + if (hostKeyPrompt != null) { + getSystemService(NotificationManager::class.java).notify( + NOTIFICATION_ID, notification("SSH host key approval required"), + ) + } else { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } else { + val signal = BlockingDetection.classify(detail) + if (signal != null && profileId.isNotEmpty()) { + val count = recordProbableFailure(profileId) + DiagnosticLog.add("event=blocking_detection result=suspected signal=${signal.name.lowercase()} consecutive=$count network=${networkKind()} profile_type=${ConfigStore(this).profile(profileId)?.config?.type?.name?.lowercase() ?: "unknown"}") + if (count >= 2) handleProbableBlocking(profileId, signal) + } + if (signal == null && requiresUserAction(detail)) { + val notice = if (isAlwaysOnMode) + "VPN connection requires attention. Check authentication, certificate, or SSH host-key settings, then reconnect Always-on VPN." + else "VPN connection requires attention. Check authentication, certificate, or SSH host-key settings." + VpnRuntimeState.updateNetworkWarning(notice) + if (isAlwaysOnMode) { + connectionBlockedForAction = true + VpnRuntimeState.update(VpnConnectionState.CONNECTING) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice)) + } else { + ConfigStore(this).setConnectionDesired(false) + VpnRuntimeState.update(VpnConnectionState.DISCONNECTED) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + return + } + val failureStage = connectionFailureStage(detail) + consecutiveStartFailures++ + if (!isAlwaysOnMode && consecutiveStartFailures >= MAX_MANUAL_START_FAILURES) { + val notice = "$failureStage Connection stopped after $consecutiveStartFailures failed attempts." + DiagnosticLog.add("event=vpn_retry result=exhausted attempts=$consecutiveStartFailures stage=${failureStageToken(detail)}") + retryStatus = null + ConfigStore(this).setConnectionDesired(false) + VpnRuntimeState.updateNetworkWarning(notice) + VpnRuntimeState.update(VpnConnectionState.DISCONNECTED) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice)) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return + } + val delay = retryDelayMs(consecutiveStartFailures) + nextStartAttemptAt = SystemClock.elapsedRealtime() + delay + retryStatus = "$failureStage Retrying in ${delay / 1_000}s (attempt ${consecutiveStartFailures + 1})." + DiagnosticLog.add("event=vpn_retry result=scheduled attempt=${consecutiveStartFailures + 1} delay_ms=$delay stage=${failureStageToken(detail)}") + VpnRuntimeState.update(VpnConnectionState.CONNECTING) + getSystemService(NotificationManager::class.java).notify( + NOTIFICATION_ID, + notification(retryStatus!!), + ) + } + } + + private fun retryDelayMs(failures: Int): Long = RETRY_DELAYS_MS[ + (failures - 1).coerceIn(0, RETRY_DELAYS_MS.lastIndex) + ] + + private fun resetRetryState() { + consecutiveStartFailures = 0 + nextStartAttemptAt = 0L + retryStatus = null + } + + private fun connectionFailureStage(detail: String): String = when { + "SSH jump handshake" in detail -> "Jump host SSH handshake failed." + "SSH destination handshake" in detail -> "Destination SSH handshake failed." + "jump host could not reach" in detail -> "Jump host could not reach the destination." + "dial jump host" in detail -> "Could not connect to the jump host." + "VPN interface could not be established" in detail -> "Android could not create the VPN interface." + else -> "Connection failed." + } + + private fun failureStageToken(detail: String): String = when { + "SSH jump handshake" in detail -> "ssh_jump_handshake" + "SSH destination handshake" in detail -> "ssh_destination_handshake" + "jump host could not reach" in detail -> "ssh_jump_direct_tcpip" + "dial jump host" in detail -> "ssh_jump_tcp_connect" + "VPN interface could not be established" in detail -> "vpn_establish" + else -> "other" + } + + private fun requiresUserAction(detail: String): Boolean { + val value = detail.lowercase() + return listOf( + "authenticate", "authentication", "credentials", "unauthorized", "forbidden", + "certificate", "x509", "host key", "ssh_host_key", "invalid config", + ).any(value::contains) + } + + @Synchronized + private fun handleProbableBlocking(profileId: String, signal: BlockingSignal) { + val store = ConfigStore(this) + val settings = store.globalConnectionSettings() + val warning = "Proxy probably blocked (${signal.name.lowercase().replace('_', ' ')})." + VpnRuntimeState.updateNetworkWarning(warning) + // DNS failover may move a hostname away from a filtered or stale address. Give + // the current profile one fresh resolution attempt before changing exit IP or + // location through profile failover. + if (freshDnsRecoveryProfiles.add(profileId)) { + store.profile(profileId)?.config?.let { profile -> + BootstrapAddressCache(this).apply { + remove(profile.host) + if (profile.type == net.megaproxy487.model.ProxyType.SSH_JUMP) remove(profile.jumpHost) + } + } + val notice = "$warning Retrying this profile with fresh encrypted DNS before failover." + VpnRuntimeState.updateNetworkWarning(notice) + DiagnosticLog.add("event=blocking_recovery action=refresh_dns profile_type=${store.profile(profileId)?.config?.type?.name?.lowercase() ?: "unknown"}") + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice)) + if (tunnel != null) stopTunnel(removeForeground = false) + return + } + if (settings.failoverMode == FailoverMode.DISABLED) { + val notice = "$warning Failover is disabled; the connection may remain unavailable." + VpnRuntimeState.updateNetworkWarning(notice) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice)) + return + } + val ordered = store.sortedProfiles() + attemptedFailoverProfiles += profileId + val currentIndex = ordered.indexOfFirst { it.id == profileId }.coerceAtLeast(0) + val afterCurrent = ordered.drop(currentIndex + 1) + ordered.take(currentIndex + 1) + val candidates = when (settings.failoverMode) { + FailoverMode.SELECTED -> afterCurrent.filter { it.id in settings.failoverProfileIds } + FailoverMode.ALL -> afterCurrent + else -> emptyList() + }.filter { it.id != profileId && it.id !in attemptedFailoverProfiles } + val next = candidates.firstOrNull() ?: run { + val notice = "$warning No eligible fallback profile remains." + failoverNotice = notice + store.setFailoverState(false, notice) + VpnRuntimeState.updateNetworkWarning(notice) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice)) + return + } + attemptedFailoverProfiles += next.id + resetRetryState() + store.setConnectionProfile(next.id) + val notice = "Failover active: switched to ${next.displayName}. Location and exit IP may have changed." + failoverNotice = notice + store.setFailoverState(true, notice) + VpnRuntimeState.updateNetworkWarning(notice) + VpnRuntimeState.updateSystem(isAlwaysOnMode, isLockdownMode, next.id) + if (tunnel != null) stopTunnel(removeForeground = false) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(notice)) + } + + private fun handleRuntimeDiagnostic(profileId: String, detail: String) { + val signal = BlockingDetection.classify(detail) ?: return + val count = recordProbableFailure(profileId) + if (count >= 2) handleProbableBlocking(profileId, signal) + } + + private fun recordProbableFailure(profileId: String): Int { + val now = SystemClock.elapsedRealtime() + val previous = probableFailureTimes.put(profileId, now) + return if (previous == null || now - previous > FAILURE_WINDOW_MS) { + probableFailureCounts[profileId] = 1 + 1 + } else { + probableFailureCounts.merge(profileId, 1, Int::plus) ?: 1 + } + } + + private fun networkKind(): String { + val manager = getSystemService(ConnectivityManager::class.java) + val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return "none" + return when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + else -> "other" + } + } + + private fun updatePassiveHealthState() { + if (!isRunning) return + // The monitor runs on the main looper. Avoid JNI/reflection/JSON work here; + // health evaluation itself is safe on this short-lived worker. + thread(name = "megaproxy-health-snapshot") { + updatePassiveHealthStateOffMain() + } + } + + private fun updatePassiveHealthStateOffMain() { + val stats = ConnectionStatsReader.snapshot() ?: return + val bytes = stats.downloadBytes + stats.uploadBytes + val trafficProgressed = bytes > lastHealthBytes + val newConnectionOutcomes = stats.totalOutcomes > lastHealthOutcomes + when { + trafficProgressed && healthWarningActive -> { + healthWarningActive = false + if (failoverNotice == null) VpnRuntimeState.updateNetworkWarning(null) + DiagnosticLog.add("event=connection_health state=recovered") + } + newConnectionOutcomes && stats.connectionSamples >= 3 && stats.connectionErrorRate >= 0.75 && !healthWarningActive -> { + healthWarningActive = true + val warning = "Connection is degraded: most recent proxy connections failed." + VpnRuntimeState.updateNetworkWarning(warning) + DiagnosticLog.add("event=connection_health state=degraded error_rate_percent=${(stats.connectionErrorRate * 100).toInt()} samples=${stats.connectionSamples}") + } + } + lastHealthBytes = bytes + lastHealthOutcomes = stats.totalOutcomes + } + + private fun registerUnderlyingNetworkCallback() { + val manager = getSystemService(ConnectivityManager::class.java) + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + .build() + runCatching { + manager.registerNetworkCallback(request, networkCallback) + networkCallbackRegistered = true + }.onFailure { + DiagnosticLog.add("event=network_monitor result=failed reason=${it.javaClass.simpleName}") + } + } + + private fun reconsiderUnderlyingNetworks(preferred: Network? = null) { + monitorHandler.post { + val validated = underlyingCapabilities.entries + .filter { it.value.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } + .maxWithOrNull( + compareBy> { networkPreference(it.value) } + .thenBy { if (it.key == preferred) 1 else 0 } + .thenBy { if (it.key == underlyingNetwork) 1 else 0 }, + )?.key + val captive = underlyingCapabilities.values.any { + it.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } + if (validated == null) { + if (captive) { + val warning = "Wi-Fi sign-in is required before the VPN can reconnect." + VpnRuntimeState.updateNetworkWarning(warning) + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(warning)) + DiagnosticLog.add("event=network state=captive_portal") + } + return@post + } + val previous = underlyingNetwork + if (previous == validated) return@post + underlyingNetwork = validated + if (tunnel != null) setUnderlyingNetworks(arrayOf(validated)) + DiagnosticLog.add("event=network underlying=changed connected=${tunnel != null}") + if (previous != null && tunnel != null) { + monitorHandler.removeCallbacks(reconnectForNetworkChange) + monitorHandler.postDelayed(reconnectForNetworkChange, NETWORK_CHANGE_DEBOUNCE_MS) + } + } + } + + private fun networkPreference(capabilities: NetworkCapabilities): Int = when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 3 + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 2 + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 1 + else -> 0 + } + + private fun stopTunnel(removeForeground: Boolean = true) { + startGeneration.incrementAndGet() + val stopped = synchronized(tunnelStateLock) { + val result = Triple(tunnel, core, tunnelTestOnly) + tunnel = null + core = null + tunnelTestOnly = false + activeConfig = null + isRunning = false + healthWarningActive = false + lastHealthBytes = 0L + lastHealthOutcomes = 0L + result + } + if (stopped.first != null) { + if (stopped.third) TestDiagnosticLog.add("Stopping temporary VPN") + else DiagnosticLog.add("Stopping VPN") + } + VpnRuntimeState.update(VpnConnectionState.DISCONNECTED) + resetRetryState() + stopped.second?.stop() + stopped.first?.close() + if (removeForeground) stopForeground(STOP_FOREGROUND_REMOVE) + } + + override fun onRevoke() { + ConfigStore(this).setConnectionDesired(false) + isAlwaysOnMode = false + isLockdownMode = false + stopTunnel() + stopSelf() + } + override fun onDestroy() { + monitorHandler.removeCallbacks(monitor) + monitorHandler.removeCallbacks(reconnectForNetworkChange) + if (networkCallbackRegistered) { + runCatching { getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) } + } + stopTunnel() + super.onDestroy() + } + + private fun createChannel() { + val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW).apply { + description = "Persistent VPN status" + setShowBadge(false) + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun configureHostKeyPrompt(message: String, profileId: String, testOnly: Boolean) { + val marker = when { + "SSH_HOST_KEY_UNKNOWN|" in message -> "SSH_HOST_KEY_UNKNOWN|" + "SSH_HOST_KEY_CHANGED|" in message -> "SSH_HOST_KEY_CHANGED|" + else -> return + } + val parts = message.substringAfter(marker).substringBefore(' ').split('|') + if (parts.size < 3) return + val changed = marker.startsWith("SSH_HOST_KEY_CHANGED") + val fingerprint = if (changed) parts.getOrNull(3) else parts.getOrNull(2) + if (fingerprint == null || !fingerprint.startsWith("SHA256:")) return + val intent = Intent(this, SshHostKeyActivity::class.java) + .putExtra(SshHostKeyActivity.EXTRA_PROFILE_ID, profileId) + .putExtra(SshHostKeyActivity.EXTRA_HOP, parts[0]) + .putExtra(SshHostKeyActivity.EXTRA_ALGORITHM, parts[1]) + .putExtra(SshHostKeyActivity.EXTRA_FINGERPRINT, fingerprint) + .putExtra(SshHostKeyActivity.EXTRA_CHANGED, changed) + .putExtra(SshHostKeyActivity.EXTRA_TEST_ONLY, testOnly) + SshHostKeyPromptState.show( + PendingSshHostKey(profileId, parts[0], parts[1], fingerprint, changed, testOnly), + ) + hostKeyPrompt = PendingIntent.getActivity( + this, profileId.hashCode() xor parts[0].hashCode(), intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun notification(text: String) = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(net.megaproxy487.R.drawable.ic_vpn_notification) + .setContentTitle("MegaProxy is active") + .setContentText(text) + .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent(hostKeyPrompt ?: PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) + .also { builder -> hostKeyPrompt?.let { builder.addAction(0, "Review SSH key", it) } } + .build() + + companion object { + private const val CHANNEL_ID = "vpn" + private const val NOTIFICATION_ID = 101 + private const val ACTION_STOP = "net.megaproxy487.STOP" + private const val ACTION_TEST = "net.megaproxy487.TEST" + private const val ACTION_RECONNECT = "net.megaproxy487.RECONNECT" + private const val ACTION_START_MANUAL = "net.megaproxy487.START_MANUAL" + private const val ACTION_REFRESH_STATUS = "net.megaproxy487.REFRESH_STATUS" + private const val ACTION_DISMISS_HOST_KEY = "net.megaproxy487.DISMISS_HOST_KEY" + private const val EXTRA_RECONNECT_PROFILE_ID = "reconnect_profile_id" + private const val EXTRA_RECONNECT_REASON = "reconnect_reason" + private const val MONITOR_INTERVAL_MS = 10_000L + private const val FAILURE_WINDOW_MS = 60_000L + private const val NETWORK_CHANGE_DEBOUNCE_MS = 1_500L + private const val MAX_MANUAL_START_FAILURES = 5 + private const val VPN_MTU = 1_400 + private val RETRY_DELAYS_MS = longArrayOf(5_000L, 10_000L, 20_000L, 40_000L, 80_000L, 180_000L, 300_000L) + private val testRunning = AtomicBoolean(false) + private val startRunning = AtomicBoolean(false) + private val commandScope = CoroutineScope(SupervisorJob() + ConfigIoDispatcher) + @Volatile var isRunning: Boolean = false + private set + @Volatile var isAlwaysOnMode: Boolean = false + private set + @Volatile var isLockdownMode: Boolean = false + private set + + fun start(context: Context) { + val app = context.applicationContext + commandScope.launch { + ConfigStore(app).setConnectionDesired(true) + ConfigStore(app).let { it.setConnectionProfile(it.activeProfileId()) } + ContextCompat.startForegroundService(app, Intent(app, ProxyVpnService::class.java).setAction(ACTION_START_MANUAL)) + } + } + fun stop(context: Context) { + val app = context.applicationContext + commandScope.launch { + ConfigStore(app).setConnectionDesired(false) + app.startService(Intent(app, ProxyVpnService::class.java).setAction(ACTION_STOP)) + } + } + fun reconnect(context: Context) { + val app = context.applicationContext + commandScope.launch { + ConfigStore(app).setConnectionDesired(true) + ContextCompat.startForegroundService(app, Intent(app, ProxyVpnService::class.java).setAction(ACTION_RECONNECT)) + } + } + /** Called from MY_PACKAGE_REPLACED after its receiver has checked persisted state. */ + fun restoreAfterPackageUpdate(context: Context) { + val app = context.applicationContext + ContextCompat.startForegroundService( + app, + Intent(app, ProxyVpnService::class.java).setAction(ACTION_RECONNECT) + .putExtra(EXTRA_RECONNECT_REASON, "package_replaced"), + ) + } + fun switchProfile(context: Context, profileId: String, useAsAlwaysOn: Boolean) { + val app = context.applicationContext + commandScope.launch { + val store = ConfigStore(app) + if (store.profile(profileId) == null) return@launch + if (useAsAlwaysOn) store.setAlwaysOnProfile(profileId) else store.setActiveProfile(profileId) + store.setConnectionProfile(profileId) + store.setConnectionDesired(true) + ContextCompat.startForegroundService( + app, + Intent(app, ProxyVpnService::class.java).setAction(ACTION_RECONNECT) + .putExtra(EXTRA_RECONNECT_PROFILE_ID, profileId) + .putExtra(EXTRA_RECONNECT_REASON, "profile_changed"), + ) + } + } + fun test(context: Context) = ContextCompat.startForegroundService( + context, Intent(context, ProxyVpnService::class.java).setAction(ACTION_TEST), + ) + fun refreshStatus(context: Context) { + if (isRunning) { + context.startService(Intent(context, ProxyVpnService::class.java).setAction(ACTION_REFRESH_STATUS)) + } + } + fun dismissHostKeyPrompt(context: Context) { + SshHostKeyPromptState.clear() + context.startService(Intent(context, ProxyVpnService::class.java).setAction(ACTION_DISMISS_HOST_KEY)) + } + } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/SshHostKeyPromptState.kt b/app/src/main/java/net/megaproxy487/vpn/SshHostKeyPromptState.kt new file mode 100644 index 0000000..01b118c --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/SshHostKeyPromptState.kt @@ -0,0 +1,32 @@ +package net.megaproxy487.vpn + +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.mutableStateOf + +data class PendingSshHostKey( + val profileId: String, + val hop: String, + val algorithm: String, + val fingerprint: String, + val changed: Boolean, + val testOnly: Boolean, +) + +object SshHostKeyPromptState { + private val mainHandler = Handler(Looper.getMainLooper()) + val pending = mutableStateOf(null) + + fun show(value: PendingSshHostKey) { + update(value) + } + + fun clear() { + update(null) + } + + private fun update(value: PendingSshHostKey?) { + if (Looper.myLooper() == Looper.getMainLooper()) pending.value = value + else mainHandler.post { pending.value = value } + } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt b/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt new file mode 100644 index 0000000..8c29564 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt @@ -0,0 +1,65 @@ +package net.megaproxy487.vpn + +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf + +enum class VpnConnectionState { DISCONNECTED, CONNECTING, CONNECTED } +enum class VpnTransportProtocol { UNKNOWN, HTTP_1_1, HTTP_2, SSH_MULTIPLEXED } + +fun transportProtocolFromDiagnostic(message: String): VpnTransportProtocol? = when { + "event=connection" in message && "protocol=http2" in message && + "stage=tunnel" in message && "result=established" in message -> VpnTransportProtocol.HTTP_2 + "event=connection" in message && "mode=proxy" in message && + "stage=tunnel" in message && "result=established" in message -> VpnTransportProtocol.HTTP_1_1 + "event=transport_capability" in message && "transport=ssh" in message && + "multiplexed=true" in message -> VpnTransportProtocol.SSH_MULTIPLEXED + else -> null +} + +object VpnRuntimeState { + private val mainHandler = Handler(Looper.getMainLooper()) + private val mutableConnection = mutableStateOf( + if (ProxyVpnService.isRunning) VpnConnectionState.CONNECTED else VpnConnectionState.DISCONNECTED, + ) + val connection: State = mutableConnection + private val mutableAlwaysOn = mutableStateOf(ProxyVpnService.isAlwaysOnMode) + private val mutableLockdown = mutableStateOf(ProxyVpnService.isLockdownMode) + private val mutableConnectionProfileId = mutableStateOf("") + val alwaysOn: State = mutableAlwaysOn + val lockdown: State = mutableLockdown + val connectionProfileId: State = mutableConnectionProfileId + private val mutableNetworkWarning = mutableStateOf(null) + val networkWarning: State = mutableNetworkWarning + private val mutableTransportProtocol = mutableStateOf(VpnTransportProtocol.UNKNOWN) + val transportProtocol: State = mutableTransportProtocol + + fun update(value: VpnConnectionState) { + val update = { + mutableConnection.value = value + if (value != VpnConnectionState.CONNECTED) mutableTransportProtocol.value = VpnTransportProtocol.UNKNOWN + } + if (Looper.myLooper() == Looper.getMainLooper()) update() else mainHandler.post(update) + } + + fun observeDiagnostic(message: String) { + val protocol = transportProtocolFromDiagnostic(message) ?: return + val update = { mutableTransportProtocol.value = protocol } + if (Looper.myLooper() == Looper.getMainLooper()) update() else mainHandler.post(update) + } + + fun updateNetworkWarning(value: String?) { + if (Looper.myLooper() == Looper.getMainLooper()) mutableNetworkWarning.value = value + else mainHandler.post { mutableNetworkWarning.value = value } + } + + fun updateSystem(alwaysOn: Boolean, lockdown: Boolean, connectionProfileId: String) { + val update = { + mutableAlwaysOn.value = alwaysOn + mutableLockdown.value = lockdown + mutableConnectionProfileId.value = connectionProfileId + } + if (Looper.myLooper() == Looper.getMainLooper()) update() else mainHandler.post(update) + } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt b/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt new file mode 100644 index 0000000..4efbe1d --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/VpnSettings.kt @@ -0,0 +1,14 @@ +package net.megaproxy487.vpn + +import android.app.Activity +import android.content.Intent +import android.provider.Settings + +const val OTHER_ALWAYS_ON_VPN_MESSAGE = + "Another app is configured as Always-on VPN. Change the Always-on VPN app in Android settings, then try again." + +fun openAndroidVpnSettings(activity: Activity) { + val vpnSettings = Intent(Settings.ACTION_VPN_SETTINGS) + runCatching { activity.startActivity(vpnSettings) } + .recoverCatching { activity.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } +} diff --git a/app/src/main/res/drawable/ic_vpn_notification.xml b/app/src/main/res/drawable/ic_vpn_notification.xml new file mode 100644 index 0000000..82e7e17 --- /dev/null +++ b/app/src/main/res/drawable/ic_vpn_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..be43858 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..be43858 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4e5afda Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f850e5e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..4e5afda Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..ef2d792 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d1c5c40 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ef2d792 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..17cc7ad Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fa349ec Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..17cc7ad Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..abb52ab Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..79bfcc7 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..abb52ab Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..39e3331 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2709ad9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..39e3331 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..7550247 --- /dev/null +++ b/app/src/main/res/values-night/styles.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..913f34f --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,63 @@ + + + Назад + Настройки + Подключение + Профили + Прокси-серверы, учётные данные, DNS и IPv6 + Постоянный VPN + Стартовый профиль и системные настройки VPN + Цифровые отпечатки + Поведение HTTPS JA3 и SSH-клиента + Раздельное туннелирование + Глобальная или маршрутизация отдельных приложений + Переключение при сбое + Резервные профили при вероятной блокировке + Диагностика + Видимость VPN + Проверка признаков, доступных приложениям + Настройки батареи + Надёжная работа VPN в фоновом режиме + Диагностический журнал + Просмотр, экспорт и очистка обезличенных событий + Интерфейс и язык + Язык + Язык интерфейса MegaProxy + Английский + Русский + Поддержка + Обратная связь + Отправить отзыв с диагностическим журналом + GitHub Issues + Сообщить об ошибке или посмотреть известные проблемы + Версия %1$s · %2$s + Подключиться + Отключиться + Подключение… + Проверить + Настроить + Не найдено приложение для открытия ссылки. + Проверка подключения + Профиль + Настройки профиля + Режим маршрутизации + Приложения + Проверить снова + Копировать + Импорт + Экспорт + Изменения сохраняются автоматически. + Отмена + Скрыть + Переподключиться сейчас + Переподключиться + Применить новые настройки + При следующем подключении + Очистить + Повторить проверку + HTTP/1.1 + HTTP/2, мультиплексирование + SSH, мультиплексирование + Импортировать ключ назначения из файла + Импортировать ключ jump host из файла + diff --git a/app/src/main/res/values/ic_launcher_colors.xml b/app/src/main/res/values/ic_launcher_colors.xml new file mode 100644 index 0000000..5d60d0a --- /dev/null +++ b/app/src/main/res/values/ic_launcher_colors.xml @@ -0,0 +1,4 @@ + + + #2725D9 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..29d2240 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,64 @@ + + + MegaProxy + Back + Settings + Connection + Profiles + Proxy servers, credentials, DNS and IPv6 + Always-on VPN + Startup profile and Android VPN controls + Fingerprints + HTTPS JA3 and SSH client behavior + Split tunneling + Choose global or per-app routing + Failover + Fallback profiles when blocking is suspected + Diagnostics + Visibility + Check what applications can detect + Battery settings + Allow reliable background VPN operation + Diagnostic log + Inspect, export or clear privacy-filtered events + Appearance and language + Language + Language used by the MegaProxy interface + English + Russian + Support + Feedback + Send feedback and attach the diagnostic log + GitHub issues + Report a bug or follow known issues + Version %1$s · %2$s + Connect + Disconnect + Connecting… + Test + Configure + No browser is available. + Connection test + Profile + Profile settings + Routing mode + Applications + Run again + Copy + Import + Export + Changes are saved automatically. + Cancel + Dismiss + Reconnect now + Reconnect + Apply new settings + Next connection + Clear + Run checks again + HTTP/1.1 + HTTP/2 multiplexed + SSH multiplexed + Import destination key from file + Import jump key from file + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..3e47392 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..3c332df --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..a2451e9 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/java/net/megaproxy487/BatteryOptimizationReminderTest.kt b/app/src/test/java/net/megaproxy487/BatteryOptimizationReminderTest.kt new file mode 100644 index 0000000..037f831 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/BatteryOptimizationReminderTest.kt @@ -0,0 +1,39 @@ +package net.megaproxy487 + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BatteryOptimizationReminderTest { + @Test + fun firstRequestIsDue() { + assertTrue(BatteryOptimizationReminder.isRequestDue(now = 1_000L, lastRequestAt = 0L)) + } + + @Test + fun requestIsSuppressedDuringDailyInterval() { + val lastRequestAt = 1_000L + assertFalse( + BatteryOptimizationReminder.isRequestDue( + now = lastRequestAt + BatteryOptimizationReminder.REQUEST_INTERVAL_MS - 1, + lastRequestAt = lastRequestAt, + ), + ) + } + + @Test + fun requestIsDueAfterOneDay() { + val lastRequestAt = 1_000L + assertTrue( + BatteryOptimizationReminder.isRequestDue( + now = lastRequestAt + BatteryOptimizationReminder.REQUEST_INTERVAL_MS, + lastRequestAt = lastRequestAt, + ), + ) + } + + @Test + fun requestIsDueIfWallClockMovesBackwards() { + assertTrue(BatteryOptimizationReminder.isRequestDue(now = 1_000L, lastRequestAt = 2_000L)) + } +} diff --git a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt new file mode 100644 index 0000000..f7f1e2b --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt @@ -0,0 +1,113 @@ +package net.megaproxy487.data + +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyProfile +import net.megaproxy487.model.TlsProfile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConfigTransferTest { + @Test + fun `proxy list omits passwords by default`() { + val profile = ProxyProfile( + id = "one", name = "Amsterdam", colorIndex = 0, countryCode = "nl", + config = ProxyConfig(host = "proxy.example", username = "user+name", password = "secret"), + ) + val exported = ConfigTransfer.exportProxyList(listOf(profile), includePasswords = false) + assertTrue(exported.contains("https://user%2Bname:@proxy.example")) + assertFalse(exported.contains("secret")) + val imported = ProxyListParser.parse(exported).getOrThrow().proxies.single() + assertEquals("", imported.config.password) + assertEquals("NL", imported.countryCode) + assertEquals("Amsterdam", imported.name) + } + + @Test + fun `proxy list includes password when requested`() { + val profile = ProxyProfile( + id = "one", colorIndex = 0, + config = ProxyConfig(host = "proxy.example", username = "user", password = "s e:c@r"), + ) + val exported = ConfigTransfer.exportProxyList(listOf(profile), includePasswords = true) + assertEquals("s e:c@r", ProxyListParser.parse(exported).getOrThrow().proxies.single().config.password) + } + + @Test + fun `json import preserves unavailable applications and tolerates future values`() { + val imported = ConfigTransfer.importJson( + """ + { + "schema":"dev.megaproxy.config", + "version":1, + "activeProfileId":"source", + "alwaysOnProfileId":"source", + "profileSort":"FUTURE_SORT", + "profiles":[{ + "id":"source", + "name":"Portable", + "color":999, + "countryCode":"de", + "proxy":{"host":"proxy.example","port":70000,"username":"u"}, + "tls":{"fingerprint":"FUTURE_BROWSER","customJa3":"future"}, + "dns":{"provider":"FUTURE_DNS","customDohUrl":"https://dns.example/query"}, + "routing":{"selectedPackages":["com.example.not.installed","invalid package"],"routeAllApps":false}, + "futureField":true + }] + } + """.trimIndent(), + ) + val profile = imported.profiles.single() + assertEquals(443, profile.config.port) + assertEquals(TlsProfile.DEFAULT, profile.config.profile) + assertEquals(setOf("com.example.not.installed"), profile.config.selectedPackages) + assertEquals("", profile.config.password) + assertEquals("source", imported.activeProfileId) + } + + @Test + fun `unsupported global fingerprint falls back to default`() { + val imported = ConfigTransfer.importJson( + """ + { + "schema":"dev.megaproxy.config", + "version":2, + "tls":{"fingerprint":"SAMSUNG_INTERNET","customJa3":"ignored"}, + "routing":{"selectedPackages":["com.example.missing"],"routeAllApps":false}, + "profiles":[{ + "id":"one", + "proxy":{"host":"proxy.example","username":"user"} + }] + } + """.trimIndent(), + ) + + assertEquals(TlsProfile.DEFAULT, imported.globalConnectionSettings?.tlsProfile) + assertEquals(setOf("com.example.missing"), imported.globalConnectionSettings?.selectedPackages) + } + + @Test + fun `version one keeps per-profile IPv6 setting`() { + val imported = ConfigTransfer.importJson( + """{"schema":"dev.megaproxy.config","version":1,"profiles":[{ + "id":"one","proxy":{"host":"proxy.example"},"routing":{"allowIpv6":true} + }]}""", + ) + + assertTrue(imported.profiles.single().config.allowIpv6) + } + + @Test + fun `legacy global IPv6 setting migrates to every profile`() { + val imported = ConfigTransfer.importJson( + """{"schema":"dev.megaproxy.config","version":5, + "routing":{"allowIpv6":true},"profiles":[ + {"id":"one","proxy":{"host":"one.example"}}, + {"id":"two","proxy":{"host":"two.example"}} + ]}""", + ) + + assertTrue(imported.profiles.all { it.config.allowIpv6 }) + } +} diff --git a/app/src/test/java/net/megaproxy487/data/FoxyProxyParserTest.kt b/app/src/test/java/net/megaproxy487/data/FoxyProxyParserTest.kt new file mode 100644 index 0000000..5b9ae80 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/FoxyProxyParserTest.kt @@ -0,0 +1,52 @@ +package net.megaproxy487.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FoxyProxyParserTest { + @Test + fun `imports only TLS proxy entries from current FoxyProxy JSON`() { + val result = FoxyProxyParser.parse( + """ + { + "mode":"pattern", + "data":[ + { + "active":true, + "title":"Amsterdam", + "type":"https", + "hostname":"proxy.example", + "port":"8443", + "username":"user", + "password":"secret", + "cc":"nl" + }, + { + "title":"Not supported", + "type":"socks5", + "hostname":"socks.example", + "port":"1080" + } + ] + } + """.trimIndent(), + ).getOrThrow() + + assertEquals(1, result.skippedNonHttps) + val proxy = result.proxies.single() + assertEquals("Amsterdam", proxy.name) + assertEquals("NL", proxy.countryCode) + assertEquals("proxy.example", proxy.config.host) + assertEquals(8443, proxy.config.port) + assertEquals("user", proxy.config.username) + assertEquals("secret", proxy.config.password) + } + + @Test + fun `rejects FoxyProxy config without HTTPS proxies`() { + val result = FoxyProxyParser.parse( + """{"data":[{"type":"http","hostname":"proxy.example","port":8080}]}""", + ) + check(result.isFailure) + } +} diff --git a/app/src/test/java/net/megaproxy487/data/LimitedInputTest.kt b/app/src/test/java/net/megaproxy487/data/LimitedInputTest.kt new file mode 100644 index 0000000..149f816 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/LimitedInputTest.kt @@ -0,0 +1,19 @@ +package net.megaproxy487.data + +import java.io.ByteArrayInputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class LimitedInputTest { + @Test + fun `reads a normal configuration`() { + assertEquals("config", ByteArrayInputStream("config".toByteArray()).readConfigText()) + } + + @Test + fun `rejects an oversized configuration`() { + val input = ByteArrayInputStream(ByteArray(MAX_CONFIG_FILE_BYTES + 1)) + assertThrows(IllegalArgumentException::class.java) { input.readConfigText() } + } +} diff --git a/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt b/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt new file mode 100644 index 0000000..d4ad2cb --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/PrivateKeyImportTest.kt @@ -0,0 +1,51 @@ +package net.megaproxy487.data + +import java.io.ByteArrayInputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class PrivateKeyImportTest { + @Test + fun importsOpenSshKeyAndNormalizesTrailingNewline() { + val input = """ + -----BEGIN OPENSSH PRIVATE KEY----- + example + -----END OPENSSH PRIVATE KEY----- + """.trimIndent() + + assertEquals("$input\n", ByteArrayInputStream(input.toByteArray()).readPrivateKeyText()) + } + + @Test + fun rejectsPublicKey() { + val input = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA example" + + assertThrows(IllegalArgumentException::class.java) { + ByteArrayInputStream(input.toByteArray()).readPrivateKeyText() + } + } + + @Test + fun rejectsEncryptedPemKey() { + val input = """ + -----BEGIN EC PRIVATE KEY----- + Proc-Type: 4,ENCRYPTED + example + -----END EC PRIVATE KEY----- + """.trimIndent() + + assertThrows(IllegalArgumentException::class.java) { + ByteArrayInputStream(input.toByteArray()).readPrivateKeyText() + } + } + + @Test + fun rejectsOversizedKeyBeforeGrowingWithoutBound() { + val input = ByteArray(MAX_PRIVATE_KEY_FILE_BYTES + 1) { 'A'.code.toByte() } + + assertThrows(IllegalArgumentException::class.java) { + ByteArrayInputStream(input).readPrivateKeyText() + } + } +} diff --git a/app/src/test/java/net/megaproxy487/data/ProxyListParserTest.kt b/app/src/test/java/net/megaproxy487/data/ProxyListParserTest.kt new file mode 100644 index 0000000..5416eb5 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/ProxyListParserTest.kt @@ -0,0 +1,40 @@ +package net.megaproxy487.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProxyListParserTest { + @Test + fun parsesHttpsProxyUriAndTitle() { + val result = ProxyListParser.parse( + "https://user:p%40ss%3Aword@example.com:8443?title=Amsterdam&cc=NL", + ).getOrThrow().proxies.single() + + assertEquals("Amsterdam", result.name) + assertEquals("NL", result.countryCode) + assertEquals("example.com", result.config.host) + assertEquals(8443, result.config.port) + assertEquals("user", result.config.username) + assertEquals("p@ss:word", result.config.password) + assertTrue(result.config.routeAllApps) + } + + @Test + fun preservesLiteralPlusInBasicAuthAndDefaultsPort() { + val result = ProxyListParser.parse("https://user:abc+123@example.com").getOrThrow().proxies.single() + + assertEquals(443, result.config.port) + assertEquals("abc+123", result.config.password) + } + + @Test + fun skipsNonHttpsProxyAndReportsIt() { + val result = ProxyListParser.parse( + "https://user:password@example.com\nhttp://user:password@example.net:8080", + ).getOrThrow() + + assertEquals(1, result.proxies.size) + assertEquals(1, result.skippedNonHttps) + } +} diff --git a/app/src/test/java/net/megaproxy487/data/SuperProxyParserTest.kt b/app/src/test/java/net/megaproxy487/data/SuperProxyParserTest.kt new file mode 100644 index 0000000..ae7bd59 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/SuperProxyParserTest.kt @@ -0,0 +1,27 @@ +package net.megaproxy487.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SuperProxyParserTest { + @Test + fun `imports Super Proxy v1 HTTPS list and ignores certificate pin`() { + val text = """ + # superproxy:proxylist:v1 + https://user:p%40ss@proxy.example?fingerprint=sha256%2Fignored + """.trimIndent() + + assertTrue(SuperProxyParser.matches(text)) + val proxy = SuperProxyParser.parse(text).getOrThrow().proxies.single() + assertEquals("proxy.example", proxy.config.host) + assertEquals(443, proxy.config.port) + assertEquals("user", proxy.config.username) + assertEquals("p@ss", proxy.config.password) + } + + @Test + fun `rejects a generic proxy list as Super Proxy config`() { + check(SuperProxyParser.parse("https://user:pass@proxy.example").isFailure) + } +} diff --git a/app/src/test/java/net/megaproxy487/model/DnsProviderTest.kt b/app/src/test/java/net/megaproxy487/model/DnsProviderTest.kt new file mode 100644 index 0000000..4219abc --- /dev/null +++ b/app/src/test/java/net/megaproxy487/model/DnsProviderTest.kt @@ -0,0 +1,31 @@ +package net.megaproxy487.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DnsProviderTest { + @Test + fun `built in providers have encrypted failover`() { + listOf(DnsProvider.CLOUDFLARE, DnsProvider.GOOGLE, DnsProvider.QUAD9, DnsProvider.YANDEX).forEach { provider -> + assertTrue(provider.url.startsWith("https://")) + assertTrue(provider.fallbackUrls().size >= 2) + assertTrue(provider.url !in provider.fallbackUrls()) + } + } + + @Test + fun `custom provider never leaks queries to implicit fallback`() { + assertEquals(emptyList(), DnsProvider.CUSTOM.fallbackUrls()) + assertEquals(emptyList(), DnsProvider.YANDEX_SAFE.fallbackUrls()) + assertEquals(emptyList(), DnsProvider.YANDEX_FAMILY.fallbackUrls()) + } + + @Test + fun `Yandex modes are available`() { + assertEquals( + setOf(DnsProvider.YANDEX, DnsProvider.YANDEX_SAFE, DnsProvider.YANDEX_FAMILY), + DnsProvider.entries.filter { it.name.startsWith("YANDEX") }.toSet(), + ) + } +} diff --git a/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt b/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt new file mode 100644 index 0000000..9663da8 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt @@ -0,0 +1,18 @@ +package net.megaproxy487.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class Ja3SpecTest { + @Test fun parsesCanonicalJa3() { + val spec = Ja3Spec.parse("771,4865-4866,0-10-11,29-23,0")!! + assertEquals(771, spec.tlsVersion) + assertEquals(listOf(4865, 4866), spec.cipherSuites) + } + + @Test fun rejectsMalformedJa3() { + assertNull(Ja3Spec.parse("771,4865,0")) + assertNull(Ja3Spec.parse("771,70000,0,29,0")) + } +} diff --git a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt new file mode 100644 index 0000000..e030249 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt @@ -0,0 +1,38 @@ +package net.megaproxy487.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ProxyConfigTest { + @Test + fun `default TLS fingerprint currently resolves to Chrome Android`() { + val resolved = GlobalConnectionSettings(tlsProfile = TlsProfile.DEFAULT) + .applyTo(ProxyConfig()) + + assertEquals(TlsProfile.CHROME_ANDROID, resolved.profile) + } + + @Test + fun `global settings preserve profile IPv6 capability`() { + val resolved = GlobalConnectionSettings().applyTo(ProxyConfig(allowIpv6 = true)) + + assertEquals(true, resolved.allowIpv6) + } + + private val validConnection = ProxyConfig( + host = "proxy.example.com", + username = "user", + password = "password", + ) + + @Test + fun splitTunnelingAllowsNoApplications() { + assertNull(validConnection.copy(routeAllApps = false).validationError()) + } + + @Test + fun globalVpnDoesNotRequireSelectedApplications() { + assertNull(validConnection.validationError()) + } +} diff --git a/app/src/test/java/net/megaproxy487/vpn/BlockingDetectionTest.kt b/app/src/test/java/net/megaproxy487/vpn/BlockingDetectionTest.kt new file mode 100644 index 0000000..da3c6d0 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/vpn/BlockingDetectionTest.kt @@ -0,0 +1,30 @@ +package net.megaproxy487.vpn + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BlockingDetectionTest { + @Test fun classifiesTimeoutAndReset() { + assertEquals(BlockingSignal.TCP_TIMEOUT, BlockingDetection.classify("dial tcp: i/o timeout")) + assertEquals(BlockingSignal.CONNECTION_RESET, BlockingDetection.classify("connection reset by peer")) + assertEquals( + BlockingSignal.TLS_HANDSHAKE_RESET, + BlockingDetection.classify("event=connection stage=tls_handshake result=failed reason=reset"), + ) + assertEquals( + BlockingSignal.CONNECT_RESPONSE_TIMEOUT, + BlockingDetection.classify("event=connection stage=connect_response result=failed reason=timeout"), + ) + assertEquals( + BlockingSignal.SSH_HANDSHAKE_TIMEOUT, + BlockingDetection.classify("SSH destination handshake: i/o timeout"), + ) + } + + @Test fun doesNotTreatAuthenticationOrCertificateErrorsAsBlocking() { + assertNull(BlockingDetection.classify("unable to authenticate: i/o timeout")) + assertNull(BlockingDetection.classify("x509 certificate error")) + assertNull(BlockingDetection.classify("connection refused")) + } +} diff --git a/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt b/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt new file mode 100644 index 0000000..6c45513 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt @@ -0,0 +1,35 @@ +package net.megaproxy487.vpn + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrivacyLogSanitizerTest { + @Test + fun removesNetworkIdentifiersCredentialsAndPackageNames() { + val sanitized = PrivacyLogSanitizer.sanitize( + "username=alice password=secret url=https://proxy.example.com/path " + + "ipv4=192.0.2.10:443 ipv6=[2001:db8::1]:443 package=com.example.privateapp " + + "email=user@example.com mac=00:11:22:33:44:55 path=/data/user/0/private/file", + ) + + listOf("alice", "secret", "proxy.example.com", "192.0.2.10", "2001:db8", "com.example.privateapp", "user@example.com", "00:11:22:33:44:55", "/data/user") + .forEach { assertFalse(sanitized.contains(it)) } + assertTrue(sanitized.contains("username=[redacted]")) + assertTrue(sanitized.contains("password=[redacted]")) + assertTrue(sanitized.contains("[url]")) + assertTrue(sanitized.contains("[ip]")) + assertTrue(sanitized.contains("[host]") || sanitized.contains("[package]")) + } + + @Test + fun keepsStructuredDiagnosticsAndFlattensLines() { + val sanitized = PrivacyLogSanitizer.sanitize( + "event=connection conn=42 stage=tls_handshake\nresult=failed reason=reset dpi_hint=possible_tls_interference", + ) + + assertFalse(sanitized.contains('\n')) + assertTrue(sanitized.contains("event=connection")) + assertTrue(sanitized.contains("dpi_hint=possible_tls_interference")) + } +} diff --git a/app/src/test/java/net/megaproxy487/vpn/TransportProtocolTest.kt b/app/src/test/java/net/megaproxy487/vpn/TransportProtocolTest.kt new file mode 100644 index 0000000..07c2a49 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/vpn/TransportProtocolTest.kt @@ -0,0 +1,23 @@ +package net.megaproxy487.vpn + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TransportProtocolTest { + @Test fun recognizesEstablishedTransportsOnly() { + assertEquals( + VpnTransportProtocol.HTTP_2, + transportProtocolFromDiagnostic("event=connection mode=proxy protocol=http2 stage=tunnel result=established"), + ) + assertEquals( + VpnTransportProtocol.HTTP_1_1, + transportProtocolFromDiagnostic("event=connection mode=proxy stage=tunnel result=established"), + ) + assertEquals( + VpnTransportProtocol.SSH_MULTIPLEXED, + transportProtocolFromDiagnostic("event=transport_capability transport=ssh multiplexed=true"), + ) + assertNull(transportProtocolFromDiagnostic("event=transport_capability transport=https_proxy h2_negotiated=true")) + } +} diff --git a/artwork/launcher-icon.png b/artwork/launcher-icon.png new file mode 100644 index 0000000..e402479 Binary files /dev/null and b/artwork/launcher-icon.png differ diff --git a/assets/branding/megaproxy-launcher-master.png b/assets/branding/megaproxy-launcher-master.png new file mode 100644 index 0000000..8f5eb7c Binary files /dev/null and b/assets/branding/megaproxy-launcher-master.png differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..1b4481a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false +} diff --git a/docs/en/index.md b/docs/en/index.md new file mode 100644 index 0000000..efe01b0 --- /dev/null +++ b/docs/en/index.md @@ -0,0 +1,403 @@ +# MegaProxy server setup + +[Русская версия](../ru/index.md) + +This guide describes production-oriented servers for MegaProxy on a current Ubuntu or Debian VPS. +It covers three transports: + +1. HTTPS proxy with TLS, Basic authentication, and HTTP/2. +2. Direct SSH transport. +3. SSH through a dedicated jump host. + +The examples favor standard protocols, a small attack surface, automatic certificate renewal, and +predictable recovery after a reboot. Replace every value written as `EXAMPLE_VALUE` before using a +configuration. + +## Choose the topology + +| Profile type | Public servers | Public ports | Best use | +| --- | ---: | --- | --- | +| HTTPS | 1 | TCP 443 and TCP 80 for certificate issuance | Efficient HTTP/2 multiplexing and simple credentials | +| SSH | 1 | TCP 22, or another chosen SSH port | Standard OpenSSH deployment and key authentication | +| SSH with Jump | 2 | Jump: TCP 22; destination: SSH reachable from jump | Separating the public entry point from the egress server | + +Use different providers or networks for failover profiles. A second profile on the same VM does +not protect against a VM, provider, route, or datacenter failure. + +## Common server baseline + +The commands below assume a fresh server and a sudo-capable administrator account. Keep the +existing administrator SSH session open until a second session has successfully connected. + +```shell +sudo apt update +sudo apt full-upgrade +sudo apt install --yes ca-certificates curl openssl ufw unattended-upgrades +sudo systemctl enable --now unattended-upgrades +``` + +Set an accurate clock; TLS and SSH diagnostics become confusing when it is wrong: + +```shell +timedatectl status +sudo systemctl enable --now systemd-timesyncd +``` + +Apply a default-deny inbound firewall policy and open only the ports required by the selected +topology. Check the provider's network firewall as well as the OS firewall. + +```shell +sudo ufw default deny incoming +sudo ufw default allow outgoing +sudo ufw allow 22/tcp +sudo ufw enable +sudo ufw status verbose +``` + +Before changing the SSH port, first open the new port and verify a new connection. Never remove the +only working administrative path to the server. + +## HTTPS with HTTP/2 + +This setup uses [GOST v3](https://v3.gost.run/en/), whose HTTP/2 proxy mode accepts CONNECT streams +over TLS. MegaProxy negotiates `h2` with ALPN and reuses the HTTP/2 connection for multiple tunnels. + +### 1. Prepare DNS and the firewall + +Create an `A` record such as `proxy.example.com` pointing to the server. Add an `AAAA` record only +if the server has working public IPv6. Wait until public DNS returns the new address. + +```shell +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +``` + +Port 80 is used only for the ACME HTTP challenge. Keep it available so certificates can renew. + +### 2. Obtain a public certificate + +Install Certbot and request a certificate while port 80 is unused: + +```shell +sudo apt install --yes certbot +sudo certbot certonly --standalone \ + --domain proxy.example.com \ + --agree-tos \ + --email admin@example.com \ + --no-eff-email +sudo certbot renew --dry-run +``` + +Certbot installations normally configure automatic renewal. MegaProxy validates the system CA, +hostname, and validity dates but does not pin a particular certificate, so routine renewal works +without changing the profile. + +### 3. Install GOST + +Use a versioned GOST v3 binary or container from the +[official releases](https://github.com/go-gost/gost/releases). Verify its published checksum and +avoid unversioned third-party packages. The container example below keeps the installation easy to +audit: + +```shell +sudo apt install --yes docker.io +sudo systemctl enable --now docker +sudo install -d -m 0700 /opt/megaproxy +``` + +Generate a long random password locally and store it in a password manager: + +```shell +openssl rand -base64 32 +``` + +Create `/opt/megaproxy/gost.yml`: + +```yaml +services: + - name: megaproxy-h2 + addr: ":443" + handler: + type: http2 + auth: + username: EXAMPLE_USERNAME + password: EXAMPLE_LONG_RANDOM_PASSWORD + listener: + type: http2 + tls: + certFile: /etc/letsencrypt/live/proxy.example.com/fullchain.pem + keyFile: /etc/letsencrypt/live/proxy.example.com/privkey.pem + options: + minVersion: VersionTLS12 + maxVersion: VersionTLS13 + alpn: + - h2 +``` + +Protect the configuration because it contains the Basic Auth password: + +```shell +sudo chmod 600 /opt/megaproxy/gost.yml +``` + +Do not configure GOST's `mitm`, protocol sniffing, Web API, metrics endpoint, or profiling endpoint. +MegaProxy needs only CONNECT forwarding, and these features are unnecessary for this deployment. + +Start a pinned GOST release. Replace `EXAMPLE_GOST_VERSION` with a reviewed v3 release tag: + +```shell +sudo docker run --detach \ + --name megaproxy-gost \ + --restart unless-stopped \ + --network host \ + --volume /opt/megaproxy/gost.yml:/etc/gost/gost.yml:ro \ + --volume /etc/letsencrypt:/etc/letsencrypt:ro \ + --env GOST_LOGGER_LEVEL=warn \ + gogost/gost:EXAMPLE_GOST_VERSION \ + -C /etc/gost/gost.yml +``` + +Inspect startup without publishing credentials: + +```shell +sudo docker ps --filter name=megaproxy-gost +sudo docker logs --tail 100 megaproxy-gost +``` + +Reload the renewed certificate by restarting the small container. Create +`/etc/letsencrypt/renewal-hooks/deploy/restart-megaproxy-gost`: + +```shell +#!/bin/sh +exec /usr/bin/docker restart megaproxy-gost +``` + +Then secure and test the hook: + +```shell +sudo chmod 750 /etc/letsencrypt/renewal-hooks/deploy/restart-megaproxy-gost +sudo certbot renew --dry-run +``` + +### 4. Verify and add the profile + +Confirm certificate verification and HTTP/2 ALPN from another machine: + +```shell +openssl s_client \ + -connect proxy.example.com:443 \ + -servername proxy.example.com \ + -alpn h2 \ + -verify_return_error '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/native/README.md b/native/README.md new file mode 100644 index 0000000..8f94692 --- /dev/null +++ b/native/README.md @@ -0,0 +1,19 @@ +# Go dataplane + +This module is bound into `megaproxy.aar` with gomobile. It embeds tun2socks/gVisor, +registers HTTPS CONNECT and SSH `direct-tcpip` transports, protects every upstream socket through Android +`VpnService.protect`, verifies HTTPS proxy certificates and SSH host keys, and uses uTLS for HTTPS ClientHello control. +SSH with Jump creates a nested SSH client through the jump session. SSH transports support TCP; +DNS is carried over DoH, while arbitrary UDP (including QUIC) is intentionally blocked. +`Start` always takes ownership of the passed duplicate TUN descriptor, including error paths. + +Build prerequisites: Go 1.26+, Android SDK/NDK and `gomobile`. + +```shell +go install golang.org/x/mobile/cmd/gomobile@latest +gomobile init +gomobile bind -target=android -androidapi 26 -o ../app/libs/megaproxy.aar ./mobile +``` + +Run `go test ./...` before producing the AAR. The dependency versions are pinned in `go.mod`; +commit the generated `go.sum` after the first successful dependency download. diff --git a/native/go.mod b/native/go.mod new file mode 100644 index 0000000..1b86f91 --- /dev/null +++ b/native/go.mod @@ -0,0 +1,31 @@ +module dev.megaproxy/native + +go 1.26.3 + +require ( + github.com/refraction-networking/utls v1.8.2 + github.com/xjasonlyu/tun2socks/v2 v2.7.0 + golang.org/x/crypto v0.55.0 + golang.org/x/net v0.58.0 + gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96 +) + +require ( + github.com/andybalholm/brotli v1.0.6 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.4 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 // indirect + golang.org/x/mod v0.39.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.49.0 // indirect +) + +tool golang.org/x/mobile/cmd/gobind diff --git a/native/go.sum b/native/go.sum new file mode 100644 index 0000000..52055f8 --- /dev/null +++ b/native/go.sum @@ -0,0 +1,60 @@ +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xjasonlyu/tun2socks/v2 v2.7.0 h1:fYEN0Q1sSanuoID8xvUTsMODbKLJJH/J5ywYoMRxIPw= +github.com/xjasonlyu/tun2socks/v2 v2.7.0/go.mod h1:XHgJ3wbs63mZxGbJawt6jjNX65aPDl+Qhn2ePPEE4KI= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/mobile v0.0.0-20260821190718-4776eadac327 h1:D/wiQ6AoTYjDtSD0HMPhU8O40NUP8EF0UmDhIYCnG4I= +golang.org/x/mobile v0.0.0-20260821190718-4776eadac327/go.mod h1:D9q8rgXu13Q3uuM+Vuy6F/DG1WF/giTPLtqQ9on5B1M= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96 h1:LZXOf4NwvAwUy/eI8P+Y3DUdYLyaCEDNPEjsL2OP2ro= +gvisor.dev/gvisor v0.0.0-20260701204157-69c2d17aea96/go.mod h1:8aLQqUBHDH8fY5y60lzmwDpMMbQCcT3EBfoSwhfaGCY= diff --git a/native/mobile/bootstrap.go b/native/mobile/bootstrap.go new file mode 100644 index 0000000..f3d8ec1 --- /dev/null +++ b/native/mobile/bootstrap.go @@ -0,0 +1,185 @@ +package mobile + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/http" + "syscall" + "time" +) + +type bootstrapResolver struct { + name string + address string + url string +} + +// Each resolver is dialled by a documented IP address, while TLS still verifies +// the provider hostname. Multiple networks block individual public resolvers, so +// bootstrap must not have a single point of failure. +var bootstrapResolvers = []bootstrapResolver{ + {name: "cloudflare", address: "1.1.1.1:443", url: "https://cloudflare-dns.com/dns-query"}, + {name: "yandex_primary", address: "77.88.8.8:443", url: "https://common.dot.dns.yandex.net/dns-query"}, + {name: "yandex_secondary", address: "77.88.8.1:443", url: "https://common.dot.dns.yandex.net/dns-query"}, + {name: "google", address: "8.8.8.8:443", url: "https://dns.google/dns-query"}, + {name: "quad9", address: "9.9.9.9:443", url: "https://dns.quad9.net/dns-query"}, +} + +// ResolveProxy bootstraps the proxy address through protected encrypted DNS. +func ResolveProxy(host string, protector Protector, reporter Reporter) (string, error) { + query, id, err := buildAQuery(host) + if err != nil { + return "", err + } + var failures []error + for _, resolver := range bootstrapResolvers { + ip, resolveErr := resolveProxyWithResolver(query, id, resolver, protector, reporter) + if resolveErr == nil { + return ip, nil + } + failures = append(failures, fmt.Errorf("%s: %w", resolver.name, resolveErr)) + report(reporter, "event=bootstrap_dns provider=%s result=failed reason=%s", resolver.name, errorClass(resolveErr)) + } + return "", fmt.Errorf("all bootstrap DoH resolvers failed: %w", errors.Join(failures...)) +} + +func resolveProxyWithResolver(query []byte, id uint16, resolver bootstrapResolver, protector Protector, reporter Reporter) (string, error) { + dialer := net.Dialer{ + Timeout: 6 * time.Second, + Control: func(_, _ string, raw syscall.RawConn) error { + var protectErr error + if err := raw.Control(func(fd uintptr) { + if !protector.Protect(int(fd)) { + protectErr = errors.New("VpnService.protect rejected bootstrap DoH socket") + } + }); err != nil { + return err + } + return protectErr + }, + } + transport := &http.Transport{ + Proxy: nil, + TLSHandshakeTimeout: 6 * time.Second, + MaxResponseHeaderBytes: 64 * 1024, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp", resolver.address) + }, + } + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 8 * time.Second} + request, err := http.NewRequest(http.MethodPost, resolver.url, bytes.NewReader(query)) + if err != nil { + return "", err + } + request.Header.Set("Accept", "application/dns-message") + request.Header.Set("Content-Type", "application/dns-message") + report(reporter, "event=bootstrap_dns stage=request result=started transport=protected_doh provider=%s", resolver.name) + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("bootstrap DoH request: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("bootstrap DoH returned %s", response.Status) + } + payload, err := io.ReadAll(io.LimitReader(response.Body, 65536)) + if err != nil { + return "", err + } + ip, err := parseAResponse(payload, id) + if err != nil { + return "", err + } + report(reporter, "event=bootstrap_dns stage=response result=success family=ipv4 provider=%s", resolver.name) + return ip, nil +} + +func buildAQuery(host string) ([]byte, uint16, error) { + var idBytes [2]byte + if _, err := rand.Read(idBytes[:]); err != nil { + return nil, 0, err + } + id := binary.BigEndian.Uint16(idBytes[:]) + message := make([]byte, 12, 256) + binary.BigEndian.PutUint16(message[0:2], id) + binary.BigEndian.PutUint16(message[2:4], 0x0100) + binary.BigEndian.PutUint16(message[4:6], 1) + for _, label := range bytes.Split([]byte(host), []byte{'.'}) { + if len(label) == 0 || len(label) > 63 { + return nil, 0, errors.New("invalid proxy hostname for bootstrap DNS") + } + message = append(message, byte(len(label))) + message = append(message, label...) + } + message = append(message, 0, 0, 1, 0, 1) + return message, id, nil +} + +func parseAResponse(message []byte, id uint16) (string, error) { + if len(message) < 12 || binary.BigEndian.Uint16(message[0:2]) != id { + return "", errors.New("invalid bootstrap DNS response") + } + if binary.BigEndian.Uint16(message[2:4])&0x000f != 0 { + return "", errors.New("bootstrap DNS returned an error") + } + questions := int(binary.BigEndian.Uint16(message[4:6])) + answers := int(binary.BigEndian.Uint16(message[6:8])) + offset := 12 + for range questions { + var err error + offset, err = skipDNSName(message, offset) + if err != nil || offset+4 > len(message) { + return "", errors.New("malformed bootstrap DNS question") + } + offset += 4 + } + for range answers { + var err error + offset, err = skipDNSName(message, offset) + if err != nil || offset+10 > len(message) { + return "", errors.New("malformed bootstrap DNS answer") + } + recordType := binary.BigEndian.Uint16(message[offset : offset+2]) + recordClass := binary.BigEndian.Uint16(message[offset+2 : offset+4]) + length := int(binary.BigEndian.Uint16(message[offset+8 : offset+10])) + offset += 10 + if offset+length > len(message) { + return "", errors.New("truncated bootstrap DNS answer") + } + if recordType == 1 && recordClass == 1 && length == net.IPv4len { + return net.IP(message[offset : offset+length]).String(), nil + } + offset += length + } + return "", errors.New("bootstrap DNS response contains no IPv4 address") +} + +func skipDNSName(message []byte, offset int) (int, error) { + for { + if offset >= len(message) { + return 0, io.ErrUnexpectedEOF + } + length := int(message[offset]) + offset++ + if length == 0 { + return offset, nil + } + if length&0xc0 == 0xc0 { + if offset >= len(message) { + return 0, io.ErrUnexpectedEOF + } + return offset + 1, nil + } + if length > 63 || offset+length > len(message) { + return 0, errors.New("invalid DNS name") + } + offset += length + } +} diff --git a/native/mobile/bootstrap_test.go b/native/mobile/bootstrap_test.go new file mode 100644 index 0000000..3fb44ad --- /dev/null +++ b/native/mobile/bootstrap_test.go @@ -0,0 +1,35 @@ +package mobile + +import ( + "net" + "net/url" + "testing" +) + +func TestBootstrapResolversUseDirectHTTPSAddresses(t *testing.T) { + if len(bootstrapResolvers) < 3 { + t.Fatalf("bootstrap resolver count = %d, want failover", len(bootstrapResolvers)) + } + for _, resolver := range bootstrapResolvers { + parsed, err := url.Parse(resolver.url) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" { + t.Fatalf("invalid resolver URL %q", resolver.url) + } + host, port, err := net.SplitHostPort(resolver.address) + if err != nil || net.ParseIP(host) == nil || port != "443" { + t.Fatalf("invalid direct resolver address %q", resolver.address) + } + } +} + +func TestBootstrapIncludesYandexRedundancy(t *testing.T) { + count := 0 + for _, resolver := range bootstrapResolvers { + if resolver.name == "yandex_primary" || resolver.name == "yandex_secondary" { + count++ + } + } + if count != 2 { + t.Fatalf("Yandex resolver count = %d, want 2", count) + } +} diff --git a/native/mobile/config.go b/native/mobile/config.go new file mode 100644 index 0000000..54a7c18 --- /dev/null +++ b/native/mobile/config.go @@ -0,0 +1,198 @@ +package mobile + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "strconv" + "strings" + + tls "github.com/refraction-networking/utls" +) + +type config struct { + Type string `json:"type"` + Host string `json:"host"` + DialHost string `json:"dialHost"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + AllowInvalidProxyCertificate bool `json:"allowInvalidProxyCertificate"` + Profile string `json:"profile"` + CustomJA3 string `json:"customJa3"` + DoHURL string `json:"dohUrl"` + DoHFallbackURLs []string `json:"dohFallbackUrls"` + AllowIPv6 bool `json:"allowIpv6"` + BypassLocalNetworks bool `json:"bypassLocalNetworks"` + PrivateKey string `json:"privateKey"` + SSHProfile string `json:"sshProfile"` + TrustedHostKey string `json:"trustedHostKey"` + AcceptAnyHostKey bool `json:"acceptAnyHostKey"` + JumpHost string `json:"jumpHost"` + JumpDialHost string `json:"jumpDialHost"` + JumpPort int `json:"jumpPort"` + JumpUsername string `json:"jumpUsername"` + JumpPassword string `json:"jumpPassword"` + JumpPrivateKey string `json:"jumpPrivateKey"` + JumpTrustedHostKey string `json:"jumpTrustedHostKey"` + JumpAcceptAnyHostKey bool `json:"jumpAcceptAnyHostKey"` + SameJumpAuthentication bool `json:"sameJumpAuthentication"` + SSHAuthMode string `json:"sshAuthMode"` + SSHKeepaliveSeconds int `json:"sshKeepaliveSeconds"` + SSHMaxChannels int `json:"sshMaxChannels"` + SSHRotationMinutes int `json:"sshRotationMinutes"` + SSHRotationMB int `json:"sshRotationMb"` +} + +func parseConfig(raw string) (config, error) { + var c config + if err := json.Unmarshal([]byte(raw), &c); err != nil { + return c, fmt.Errorf("decode config: %w", err) + } + c.Host = strings.TrimSpace(c.Host) + c.DialHost = strings.TrimSpace(c.DialHost) + if c.Type == "" { + c.Type = "HTTPS" + } + if c.Host == "" || strings.ContainsAny(c.Host, "/: \t\r\n") { + return c, errors.New("invalid proxy hostname") + } + if net.ParseIP(c.DialHost) == nil { + return c, errors.New("proxy bootstrap IP is missing or invalid") + } + if c.Port < 1 || c.Port > 65535 { + return c, errors.New("invalid proxy port") + } + if c.Type == "HTTPS" && (c.Username == "" || c.Password == "") { + return c, errors.New("basic auth credentials are required") + } + if c.Type != "HTTPS" && c.Username == "" { + return c, errors.New("SSH username is required") + } + if c.Type == "SSH_JUMP" { + if c.JumpHost == "" || strings.ContainsAny(c.JumpHost, "/: \t\r\n") { + return c, errors.New("invalid jump hostname") + } + if net.ParseIP(c.JumpDialHost) == nil { + return c, errors.New("jump bootstrap IP is missing or invalid") + } + if c.JumpPort < 1 || c.JumpPort > 65535 { + return c, errors.New("invalid jump port") + } + if c.SameJumpAuthentication { + c.JumpUsername, c.JumpPassword, c.JumpPrivateKey = c.Username, c.Password, c.PrivateKey + } + if c.JumpUsername == "" { + return c, errors.New("jump SSH username is required") + } + } + if err := validateDoHURL(c.DoHURL); err != nil { + return c, err + } + for _, fallbackURL := range c.DoHFallbackURLs { + if err := validateDoHURL(fallbackURL); err != nil { + return c, fmt.Errorf("invalid fallback DoH URL: %w", err) + } + } + if c.Type == "HTTPS" { + if _, err := c.helloID(); err != nil { + return c, err + } + } + if c.Type != "HTTPS" && c.Type != "SSH" && c.Type != "SSH_JUMP" { + return c, fmt.Errorf("unsupported proxy type %q", c.Type) + } + if c.SSHAuthMode == "" { + c.SSHAuthMode = "AUTO" + } + if c.SSHAuthMode != "AUTO" && c.SSHAuthMode != "PASSWORD_ONLY" && c.SSHAuthMode != "KEY_ONLY" { + return c, errors.New("invalid SSH authentication mode") + } + if c.SSHMaxChannels == 0 { + c.SSHMaxChannels = 32 + } + if c.SSHMaxChannels < 1 || c.SSHMaxChannels > 256 { + return c, errors.New("invalid SSH channel limit") + } + return c, nil +} + +func (c config) address() string { return net.JoinHostPort(c.DialHost, strconv.Itoa(c.Port)) } + +func (c config) displayAddress() string { return net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) } +func (c config) jumpAddress() string { + return net.JoinHostPort(c.JumpDialHost, strconv.Itoa(c.JumpPort)) +} + +func (c config) helloID() (tls.ClientHelloID, error) { + switch c.Profile { + case "CHROME_ANDROID": + return tls.HelloChrome_133, nil + case "FIREFOX_ANDROID": + return tls.HelloFirefox_120, nil + case "EDGE_ANDROID": + return tls.HelloEdge_85, nil + case "RANDOMIZED": + return tls.HelloRandomizedALPN, nil + case "CUSTOM": + if _, err := parseJA3(c.CustomJA3); err != nil { + return tls.ClientHelloID{}, err + } + return tls.HelloCustom, nil + default: + return tls.ClientHelloID{}, fmt.Errorf("unsupported TLS profile %q", c.Profile) + } +} + +type ja3Spec struct { + Version uint16 + Ciphers []uint16 + Extensions []uint16 + Groups []uint16 + Points []uint8 +} + +func parseJA3(raw string) (ja3Spec, error) { + var out ja3Spec + if len(raw) > 8192 { + return out, errors.New("JA3 is too long") + } + fields := strings.Split(strings.TrimSpace(raw), ",") + if len(fields) != 5 { + return out, errors.New("JA3 must contain five comma-separated fields") + } + values := make([][]uint16, 5) + for i, field := range fields { + if field == "" && i > 0 { + continue + } + for _, token := range strings.Split(field, "-") { + if len(values[i]) >= 256 { + return out, errors.New("JA3 field contains too many values") + } + n, err := strconv.ParseUint(token, 10, 16) + if err != nil { + return out, fmt.Errorf("invalid JA3 number %q", token) + } + values[i] = append(values[i], uint16(n)) + } + } + if len(values[0]) != 1 { + return out, errors.New("JA3 TLS version is required") + } + out.Version, out.Ciphers, out.Extensions, out.Groups = values[0][0], values[1], values[2], values[3] + if out.Version != tls.VersionTLS12 && out.Version != tls.VersionTLS13 { + return out, errors.New("JA3 TLS version must be TLS 1.2 or TLS 1.3") + } + if len(out.Ciphers) == 0 { + return out, errors.New("JA3 must contain at least one cipher suite") + } + for _, point := range values[4] { + if point > 255 { + return out, errors.New("EC point format exceeds 255") + } + out.Points = append(out.Points, uint8(point)) + } + return out, nil +} diff --git a/native/mobile/config_test.go b/native/mobile/config_test.go new file mode 100644 index 0000000..2496127 --- /dev/null +++ b/native/mobile/config_test.go @@ -0,0 +1,45 @@ +package mobile + +import ( + "strings" + "testing" +) + +func TestParseJA3(t *testing.T) { + s, err := parseJA3("771,4865-4866,0-10-11-13-16-43-51,29-23,0") + if err != nil { + t.Fatal(err) + } + if s.Version != 771 || len(s.Ciphers) != 2 || len(s.Extensions) != 7 { + t.Fatalf("unexpected spec: %#v", s) + } +} + +func TestParseJA3RejectsUnsafeOrUnboundedInput(t *testing.T) { + for _, raw := range []string{ + "769,4865,0,29,0", + "771,,0,29,0", + strings.Repeat("1", 8193), + "771," + strings.TrimSuffix(strings.Repeat("4865-", 257), "-") + ",0,29,0", + } { + if _, err := parseJA3(raw); err == nil { + t.Fatalf("parseJA3(%q) unexpectedly succeeded", raw) + } + } +} + +func TestConfigRejectsIPOrSchemeLikeHost(t *testing.T) { + _, err := parseConfig(`{"host":"https://proxy.example","dialHost":"192.0.2.1","port":443,"username":"u","password":"p","profile":"CHROME_ANDROID","dohUrl":"https://dns.google/dns-query"}`) + if err == nil { + t.Fatal("expected invalid hostname") + } +} + +func TestDoHRequiresHTTPS(t *testing.T) { + if validateDoHURL("http://dns.example/query") == nil { + t.Fatal("expected HTTPS requirement") + } + if validateDoHURL("https://dns.example/dns-query") != nil { + t.Fatal("expected valid DoH URL") + } +} diff --git a/native/mobile/diagnostics.go b/native/mobile/diagnostics.go new file mode 100644 index 0000000..06fe3f7 --- /dev/null +++ b/native/mobile/diagnostics.go @@ -0,0 +1,61 @@ +package mobile + +import ( + "errors" + "fmt" + "io" + "net" + "strings" + "sync/atomic" +) + +var diagnosticConnectionSequence atomic.Uint64 + +// Reporter is implemented by Android and receives sanitized diagnostic events. +type Reporter interface { + Report(message string) +} + +func nextDiagnosticConnectionID() uint64 { return diagnosticConnectionSequence.Add(1) } + +func errorClass(err error) string { + if err == nil { + return "none" + } + if errors.Is(err, io.EOF) { + return "eof" + } + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + return "timeout" + } + message := strings.ToLower(err.Error()) + switch { + case strings.Contains(message, "reset"): + return "reset" + case strings.Contains(message, "refused"): + return "refused" + case strings.Contains(message, "unreachable"): + return "unreachable" + case strings.Contains(message, "certificate") || strings.Contains(message, "x509"): + return "certificate" + case strings.Contains(message, "tls") && strings.Contains(message, "alert"): + return "tls_alert" + case strings.Contains(message, "protect rejected"): + return "vpn_protect" + default: + return "other" + } +} + +func tlsInterferenceHint(reason string) string { + if reason == "reset" || reason == "eof" || reason == "timeout" { + return "possible_tls_interference" + } + return "none" +} + +func report(reporter Reporter, format string, args ...any) { + if reporter != nil { + reporter.Report(fmt.Sprintf(format, args...)) + } +} diff --git a/native/mobile/dialer.go b/native/mobile/dialer.go new file mode 100644 index 0000000..ea774e7 --- /dev/null +++ b/native/mobile/dialer.go @@ -0,0 +1,478 @@ +package mobile + +import ( + "bufio" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + tls "github.com/refraction-networking/utls" + M "github.com/xjasonlyu/tun2socks/v2/metadata" +) + +var errUDPBlocked = errors.New("UDP is intentionally blocked") + +// Protector is implemented by Android and calls VpnService.protect(fd). +type Protector interface{ Protect(fd int) bool } + +type httpsConnectDialer struct { + config config + protector Protector + reporter Reporter + cacheMu sync.Mutex + sessionCache tls.ClientSessionCache + dohClient *http.Client + dohInFlight chan struct{} + connections chan struct{} + h2Session *http2ConnectSession + h2Disabled bool +} + +func (d *httpsConnectDialer) Close() error { + d.cacheMu.Lock() + defer d.cacheMu.Unlock() + if d.dohClient != nil { + d.dohClient.CloseIdleConnections() + d.dohClient = nil + } + if d.h2Session != nil { + _ = d.h2Session.close() + d.h2Session = nil + } + return nil +} + +func (d *httpsConnectDialer) DialContext(ctx context.Context, metadata *M.Metadata) (net.Conn, error) { + d.cacheMu.Lock() + if d.connections == nil { + d.connections = make(chan struct{}, 256) + } + connections := d.connections + d.cacheMu.Unlock() + select { + case connections <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + connection, err := d.connectTarget(ctx, metadata.DestinationAddress()) + if err != nil { + <-connections + return nil, err + } + return &slotConn{Conn: connection, release: func() { <-connections }}, nil +} + +func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) (net.Conn, error) { + connectionID := nextDiagnosticConnectionID() + totalStarted := time.Now() + if !d.config.AllowIPv6 { + host, _, err := net.SplitHostPort(target) + if err == nil { + if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { + err = errors.New("IPv6 destination blocked by IPv4-only mode") + report(d.reporter, "event=connection conn=%d stage=policy result=blocked reason=ipv6_disabled", connectionID) + return nil, err + } + } + } + if d.config.BypassLocalNetworks && isLocalNetworkTarget(target) { + report(d.reporter, "event=connection conn=%d mode=direct stage=tcp_connect result=started", connectionID) + directStarted := time.Now() + connection, err := d.protectedDialer().DialContext(ctx, "tcp", target) + if err != nil { + err = fmt.Errorf("dial local network directly: %w", err) + report(d.reporter, "event=connection conn=%d mode=direct stage=tcp_connect result=failed reason=%s elapsed_ms=%d", connectionID, errorClass(err), time.Since(directStarted).Milliseconds()) + return nil, err + } + report(d.reporter, "event=connection conn=%d mode=direct stage=tcp_connect result=success elapsed_ms=%d", connectionID, time.Since(directStarted).Milliseconds()) + return &diagnosticConn{Conn: connection, connectionID: connectionID, reporter: d.reporter}, nil + } + if session := d.currentHTTP2Session(); session != nil { + connection, err := d.openHTTP2Tunnel(ctx, session, target, connectionID, totalStarted, true) + if err == nil { + return connection, nil + } + if shouldFallbackToHTTP1(err) { + d.disableHTTP2(session) + report(d.reporter, "event=http2_session result=unsupported action=fallback_http1") + return d.connectTarget(ctx, target) + } + d.invalidateHTTP2Session(session) + report(d.reporter, "event=http2_session result=stale action=reconnect reason=%s", errorClass(err)) + } + report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=started fingerprint=%s", connectionID, d.config.Profile) + dialStarted := time.Now() + raw, err := d.protectedDialer().DialContext(ctx, "tcp", d.config.address()) + if err != nil { + recordConnectionOutcome(false) + err = fmt.Errorf("dial HTTPS proxy: %w", err) + report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=failed reason=%s elapsed_ms=%d", connectionID, errorClass(err), time.Since(dialStarted).Milliseconds()) + return nil, err + } + recordProxyLatency(time.Since(dialStarted)) + report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=success elapsed_ms=%d", connectionID, time.Since(dialStarted).Milliseconds()) + closeOnError := true + defer func() { + if closeOnError { + _ = raw.Close() + } + }() + + hello, err := d.config.helloID() + if err != nil { + recordConnectionOutcome(false) + report(d.reporter, "event=connection conn=%d mode=proxy stage=fingerprint result=failed reason=invalid_configuration", connectionID) + return nil, err + } + uconn := tls.UClient(raw, &tls.Config{ + ServerName: d.config.Host, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: d.config.AllowInvalidProxyCertificate, + ClientSessionCache: d.clientSessionCache(), + }, hello) + if hello == tls.HelloCustom { + if err := applyJA3(uconn, d.config.CustomJA3, d.config.Host); err != nil { + recordConnectionOutcome(false) + report(d.reporter, "event=connection conn=%d mode=proxy stage=fingerprint result=failed reason=invalid_ja3", connectionID) + return nil, err + } + } + if d.isHTTP2Disabled() { + if err := forceHTTP11ALPN(uconn); err != nil { + recordConnectionOutcome(false) + return nil, fmt.Errorf("configure HTTP/1.1 ALPN fallback: %w", err) + } + } + tlsStarted := time.Now() + handshakeContext, cancelHandshake := context.WithTimeout(ctx, 15*time.Second) + err = uconn.HandshakeContext(handshakeContext) + cancelHandshake() + if err != nil { + recordConnectionOutcome(false) + reason := errorClass(err) + report(d.reporter, "event=connection conn=%d mode=proxy stage=tls_handshake result=failed reason=%s elapsed_ms=%d dpi_hint=%s fingerprint=%s", connectionID, reason, time.Since(tlsStarted).Milliseconds(), tlsInterferenceHint(reason), d.config.Profile) + err = fmt.Errorf("TLS handshake with proxy: %w", err) + return nil, err + } + tlsState := uconn.ConnectionState() + report(d.reporter, "event=connection conn=%d mode=proxy stage=tls_handshake result=success elapsed_ms=%d version=0x%04x cipher=0x%04x alpn=%q certificates=%d", connectionID, time.Since(tlsStarted).Milliseconds(), tlsState.Version, tlsState.CipherSuite, tlsState.NegotiatedProtocol, len(tlsState.PeerCertificates)) + h2Negotiated := tlsState.NegotiatedProtocol == "h2" + report(d.reporter, "event=transport_capability transport=https_proxy tls_version=0x%04x outer_alpn=%s h2_negotiated=%t h2_connect_supported=true connect_protocol=%s session_resumed=%t", tlsState.Version, normalizedALPN(tlsState.NegotiatedProtocol), h2Negotiated, connectProtocol(h2Negotiated), tlsState.DidResume) + if h2Negotiated { + session, sessionErr := newHTTP2ConnectSession(uconn) + if sessionErr != nil { + recordConnectionOutcome(false) + return nil, fmt.Errorf("initialize HTTP/2 proxy session: %w", sessionErr) + } + closeOnError = false // The HTTP/2 session now owns the outer TLS connection. + session = d.installHTTP2Session(session) + connection, connectErr := d.openHTTP2Tunnel(ctx, session, target, connectionID, totalStarted, false) + if shouldFallbackToHTTP1(connectErr) { + d.disableHTTP2(session) + report(d.reporter, "event=http2_session result=unsupported action=fallback_http1") + return d.connectTarget(ctx, target) + } + return connection, connectErr + } + deadline := time.Now().Add(15 * time.Second) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + if err := uconn.SetDeadline(deadline); err != nil { + return nil, fmt.Errorf("set CONNECT deadline: %w", err) + } + + auth := base64.StdEncoding.EncodeToString([]byte(d.config.Username + ":" + d.config.Password)) + if _, err := fmt.Fprintf(uconn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: Basic %s\r\nProxy-Connection: Keep-Alive\r\n\r\n", target, target, auth); err != nil { + recordConnectionOutcome(false) + err = fmt.Errorf("write CONNECT: %w", err) + report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_write result=failed reason=%s", connectionID, errorClass(err)) + return nil, err + } + // A peer-controlled CONNECT header must not be allowed to grow the process heap + // without bound. Any tunneled bytes already buffered after the header are retained. + reader := bufio.NewReader(&limitedHeaderReader{reader: uconn, remaining: 64 * 1024}) + response, err := http.ReadResponse(reader, &http.Request{Method: http.MethodConnect}) + if err != nil { + recordConnectionOutcome(false) + err = fmt.Errorf("read CONNECT response: %w", err) + reason := errorClass(err) + report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_response result=failed reason=%s dpi_hint=%s", connectionID, reason, tlsInterferenceHint(reason)) + return nil, err + } + if response.StatusCode != http.StatusOK { + recordConnectionOutcome(false) + _ = response.Body.Close() + err = fmt.Errorf("proxy CONNECT returned %s", response.Status) + report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_response result=rejected status=%d status_class=%dxx", connectionID, response.StatusCode, response.StatusCode/100) + return nil, err + } + if err := uconn.SetDeadline(time.Time{}); err != nil { + return nil, fmt.Errorf("clear CONNECT deadline: %w", err) + } + recordConnectionOutcome(true) + report(d.reporter, "event=connection conn=%d mode=proxy stage=tunnel result=established total_ms=%d", connectionID, time.Since(totalStarted).Milliseconds()) + closeOnError = false + var connection net.Conn = uconn + if reader.Buffered() > 0 { + connection = &bufferedConn{Conn: uconn, reader: reader} + } + return &diagnosticConn{Conn: connection, connectionID: connectionID, reporter: d.reporter}, nil +} + +type http2ConnectStatusError struct{ status int } + +func (e *http2ConnectStatusError) Error() string { + return fmt.Sprintf("HTTP/2 proxy CONNECT returned status %d", e.status) +} + +func shouldFallbackToHTTP1(err error) bool { + var statusError *http2ConnectStatusError + return errors.As(err, &statusError) && (statusError.status == http.StatusMethodNotAllowed || statusError.status == http.StatusNotImplemented) +} + +func connectProtocol(h2 bool) string { + if h2 { + return "http2" + } + return "http1_1" +} + +func (d *httpsConnectDialer) currentHTTP2Session() *http2ConnectSession { + d.cacheMu.Lock() + defer d.cacheMu.Unlock() + if d.h2Session != nil && d.h2Session.canTakeRequest() { + return d.h2Session + } + return nil +} + +func (d *httpsConnectDialer) installHTTP2Session(candidate *http2ConnectSession) *http2ConnectSession { + d.cacheMu.Lock() + defer d.cacheMu.Unlock() + if d.h2Session != nil && d.h2Session.canTakeRequest() { + _ = candidate.close() + return d.h2Session + } + if d.h2Session != nil { + _ = d.h2Session.close() + } + d.h2Session = candidate + report(d.reporter, "event=http2_session result=established multiplexed=true") + return candidate +} + +func (d *httpsConnectDialer) invalidateHTTP2Session(session *http2ConnectSession) { + d.cacheMu.Lock() + defer d.cacheMu.Unlock() + if d.h2Session == session { + d.h2Session = nil + _ = session.close() + } +} + +func (d *httpsConnectDialer) disableHTTP2(session *http2ConnectSession) { + d.cacheMu.Lock() + defer d.cacheMu.Unlock() + d.h2Disabled = true + if d.h2Session == session { + d.h2Session = nil + _ = session.close() + } +} + +func (d *httpsConnectDialer) isHTTP2Disabled() bool { + d.cacheMu.Lock() + defer d.cacheMu.Unlock() + return d.h2Disabled +} + +func (d *httpsConnectDialer) openHTTP2Tunnel(ctx context.Context, session *http2ConnectSession, target string, connectionID uint64, totalStarted time.Time, reused bool) (net.Conn, error) { + auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(d.config.Username+":"+d.config.Password)) + started := time.Now() + tunnel, status, err := session.openTunnel(ctx, target, auth) + if err != nil { + recordConnectionOutcome(false) + if status != 0 { + report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=connect_response result=rejected status=%d status_class=%dxx reused_session=%t", connectionID, status, status/100, reused) + return nil, &http2ConnectStatusError{status: status} + } + report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=connect_response result=failed reason=%s reused_session=%t", connectionID, errorClass(err), reused) + return nil, fmt.Errorf("HTTP/2 proxy CONNECT: %w", err) + } + recordProxyLatency(time.Since(started)) + recordConnectionOutcome(true) + report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=tunnel result=established stream_multiplexed=true reused_session=%t total_ms=%d", connectionID, reused, time.Since(totalStarted).Milliseconds()) + return &diagnosticConn{Conn: tunnel, connectionID: connectionID, reporter: d.reporter}, nil +} + +func normalizedALPN(value string) string { + if value == "" { + return "none" + } + return strings.ReplaceAll(value, " ", "_") +} + +func forceHTTP11ALPN(connection *tls.UConn) error { + if err := connection.BuildHandshakeState(); err != nil { + return err + } + for _, extension := range connection.Extensions { + if alpn, ok := extension.(*tls.ALPNExtension); ok { + alpn.AlpnProtocols = []string{"http/1.1"} + return nil + } + } + connection.Extensions = append(connection.Extensions, &tls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}) + return nil +} + +func (d *httpsConnectDialer) clientSessionCache() tls.ClientSessionCache { + d.cacheMu.Lock() + defer d.cacheMu.Unlock() + if d.sessionCache == nil { + d.sessionCache = tls.NewLRUClientSessionCache(32) + } + return d.sessionCache +} + +func (d *httpsConnectDialer) protectedDialer() *net.Dialer { + return &net.Dialer{ + Timeout: 15 * time.Second, + KeepAlive: 30 * time.Second, + Control: func(_, _ string, raw syscall.RawConn) error { + var protectErr error + if err := raw.Control(func(fd uintptr) { + if !d.protector.Protect(int(fd)) { + protectErr = errors.New("VpnService.protect rejected upstream socket") + } + }); err != nil { + return err + } + return protectErr + }, + } +} + +func isLocalNetworkTarget(target string) bool { + host, _, err := net.SplitHostPort(target) + if err != nil { + return false + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() +} + +func (d *httpsConnectDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, error) { + if metadata.DstPort != 53 { + return nil, errUDPBlocked + } + d.cacheMu.Lock() + if d.dohClient == nil { + d.dohClient = newDoHHTTPClient(d.connectTarget) + } + if d.dohInFlight == nil { + d.dohInFlight = make(chan struct{}, 8) + } + client := d.dohClient + limiter := d.dohInFlight + d.cacheMu.Unlock() + return newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter), nil +} + +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { return c.reader.Read(p) } + +type limitedHeaderReader struct { + reader io.Reader + remaining int + window uint32 + done bool +} + +func (r *limitedHeaderReader) Read(p []byte) (int, error) { + if r.done { + return r.reader.Read(p) + } + if r.remaining <= 0 { + return 0, errors.New("proxy CONNECT response headers exceed 64 KB") + } + if len(p) > r.remaining { + p = p[:r.remaining] + } + n, err := r.reader.Read(p) + r.remaining -= n + for _, value := range p[:n] { + r.window = r.window<<8 | uint32(value) + if r.window == 0x0d0a0d0a { + r.done = true + break + } + } + return n, err +} + +type diagnosticConn struct { + net.Conn + connectionID uint64 + reporter Reporter + once sync.Once + transferred atomic.Uint64 +} + +type slotConn struct { + net.Conn + release func() + once sync.Once +} + +func (c *slotConn) Close() error { + err := c.Conn.Close() + c.once.Do(c.release) + return err +} + +func (c *diagnosticConn) reportError(operation string, err error) { + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { + c.once.Do(func() { + reason := errorClass(err) + report(c.reporter, "event=connection conn=%d stage=tunnel_io result=failed operation=%s reason=%s transferred_bytes=%d dpi_hint=%s", c.connectionID, operation, reason, c.transferred.Load(), tlsInterferenceHint(reason)) + }) + } +} + +func (c *diagnosticConn) Read(p []byte) (int, error) { + n, err := c.Conn.Read(p) + if n > 0 { + telemetry.downloadBytes.Add(uint64(n)) + c.transferred.Add(uint64(n)) + } + c.reportError("read", err) + return n, err +} + +func (c *diagnosticConn) Write(p []byte) (int, error) { + n, err := c.Conn.Write(p) + if n > 0 { + telemetry.uploadBytes.Add(uint64(n)) + c.transferred.Add(uint64(n)) + } + c.reportError("write", err) + return n, err +} diff --git a/native/mobile/dialer_test.go b/native/mobile/dialer_test.go new file mode 100644 index 0000000..094ee8d --- /dev/null +++ b/native/mobile/dialer_test.go @@ -0,0 +1,54 @@ +package mobile + +import ( + "bytes" + "io" + "testing" +) + +func TestLocalNetworkTargets(t *testing.T) { + local := []string{ + "10.0.0.1:443", + "172.16.0.1:80", + "192.168.1.20:8080", + "127.0.0.1:3000", + "169.254.1.1:22", + "[fd00::1]:443", + "[fe80::1]:443", + } + for _, target := range local { + if !isLocalNetworkTarget(target) { + t.Errorf("expected local target: %s", target) + } + } +} + +func TestLimitedHeaderReaderRejectsOversizedHeaders(t *testing.T) { + raw := bytes.Repeat([]byte{'x'}, 32) + reader := &limitedHeaderReader{reader: bytes.NewReader(raw), remaining: 16} + if _, err := io.ReadAll(reader); err == nil { + t.Fatal("expected oversized CONNECT headers to fail") + } +} + +func TestLimitedHeaderReaderBecomesUnlimitedAfterHeaders(t *testing.T) { + body := bytes.Repeat([]byte{'b'}, 128) + raw := append([]byte("HTTP/1.1 200 OK\r\n\r\n"), body...) + reader := &limitedHeaderReader{reader: bytes.NewReader(raw), remaining: 64} + got, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, raw) { + t.Fatalf("unexpected data length: got %d, want %d", len(got), len(raw)) + } +} + +func TestPublicAndHostnameTargetsAreNotLocal(t *testing.T) { + public := []string{"1.1.1.1:443", "8.8.8.8:53", "example.com:443", "[2606:4700:4700::1111]:443"} + for _, target := range public { + if isLocalNetworkTarget(target) { + t.Errorf("expected non-local target: %s", target) + } + } +} diff --git a/native/mobile/doh.go b/native/mobile/doh.go new file mode 100644 index 0000000..b642b9c --- /dev/null +++ b/native/mobile/doh.go @@ -0,0 +1,277 @@ +package mobile + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +func validateDoHURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.Path == "" { + return errors.New("invalid HTTPS DoH URL") + } + if u.User != nil || u.Fragment != "" { + return errors.New("DoH URL must not contain credentials or fragment") + } + if port := u.Port(); port != "" { + value, parseErr := strconv.Atoi(port) + if parseErr != nil || value < 1 || value > 65535 { + return errors.New("DoH URL contains an invalid port") + } + } + return nil +} + +type dnsReply struct { + payload []byte + addr net.Addr + err error +} + +type dohPacketConn struct { + config config + reporter Reporter + connect func(context.Context, string) (net.Conn, error) + endpoints []string + endpointMu sync.Mutex + activeEndpoint int + replies chan dnsReply + inFlight chan struct{} + closed chan struct{} + closeOnce sync.Once + deadlineMu sync.Mutex + readDeadline time.Time + client *http.Client + context context.Context + cancel context.CancelFunc +} + +func newDoHPacketConn(c config, reporter Reporter, connect func(context.Context, string) (net.Conn, error), endpoint string) *dohPacketConn { + return newDoHPacketConnWithClient(c, reporter, connect, endpoint, newDoHHTTPClient(connect), nil) +} + +func newDoHHTTPClient(connect func(context.Context, string) (net.Conn, error)) *http.Client { + transport := &http.Transport{ + Proxy: nil, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 15 * time.Second, + MaxConnsPerHost: 2, + MaxIdleConnsPerHost: 2, + IdleConnTimeout: 90 * time.Second, + MaxResponseHeaderBytes: 64 * 1024, + DialContext: func(ctx context.Context, _, address string) (net.Conn, error) { + return connect(ctx, address) + }, + } + return &http.Client{Transport: transport, Timeout: 20 * time.Second} +} + +func newDoHPacketConnWithClient(c config, reporter Reporter, connect func(context.Context, string) (net.Conn, error), endpoint string, client *http.Client, limiter chan struct{}) *dohPacketConn { + ctx, cancel := context.WithCancel(context.Background()) + if limiter == nil { + limiter = make(chan struct{}, 8) + } + endpoints := []string{endpoint} + for _, fallback := range c.DoHFallbackURLs { + if fallback != endpoint { + endpoints = append(endpoints, fallback) + } + } + return &dohPacketConn{ + config: c, reporter: reporter, connect: connect, endpoints: endpoints, replies: make(chan dnsReply, 16), closed: make(chan struct{}), + inFlight: limiter, client: client, context: ctx, cancel: cancel, + } +} + +func (c *dohPacketConn) WriteTo(payload []byte, addr net.Addr) (int, error) { + select { + case <-c.closed: + return 0, net.ErrClosed + default: + } + if !c.config.AllowIPv6 { + response, isAAAA, err := emptyAAAAResponse(payload) + if err != nil { + return 0, err + } + if isAAAA { + report(c.reporter, "event=doh result=suppressed query_type=aaaa reason=ipv4_only") + c.deliver(dnsReply{payload: response, addr: addr}) + return len(payload), nil + } + } + query := append([]byte(nil), payload...) + select { + case c.inFlight <- struct{}{}: + case <-c.closed: + return 0, net.ErrClosed + case <-c.context.Done(): + return 0, c.context.Err() + } + go func() { + defer func() { <-c.inFlight }() + var lastErr error + for _, candidate := range c.endpointOrder() { + body, err := c.queryEndpoint(candidate.url, query) + if err == nil { + c.setActiveEndpoint(candidate.index) + c.deliver(dnsReply{payload: body, addr: addr}) + report(c.reporter, "event=doh result=success provider_index=%d", candidate.index) + return + } + lastErr = err + report(c.reporter, "event=doh result=failed provider_index=%d reason=%s", candidate.index, errorClass(err)) + } + c.deliver(dnsReply{addr: addr, err: fmt.Errorf("all DoH providers failed: %w", lastErr)}) + }() + return len(payload), nil +} + +type indexedDoHEndpoint struct { + index int + url string +} + +func (c *dohPacketConn) endpointOrder() []indexedDoHEndpoint { + c.endpointMu.Lock() + start := c.activeEndpoint + c.endpointMu.Unlock() + result := make([]indexedDoHEndpoint, 0, len(c.endpoints)) + for offset := range len(c.endpoints) { + index := (start + offset) % len(c.endpoints) + result = append(result, indexedDoHEndpoint{index: index, url: c.endpoints[index]}) + } + return result +} + +func (c *dohPacketConn) setActiveEndpoint(index int) { + c.endpointMu.Lock() + c.activeEndpoint = index + c.endpointMu.Unlock() +} + +func (c *dohPacketConn) queryEndpoint(endpoint string, query []byte) ([]byte, error) { + requestContext, cancel := context.WithTimeout(c.context, 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, bytes.NewReader(query)) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/dns-message") + req.Header.Set("Content-Type", "application/dns-message") + response, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("DoH returned %s", response.Status) + } + if !strings.HasPrefix(response.Header.Get("Content-Type"), "application/dns-message") { + return nil, errors.New("DoH returned an unexpected content type") + } + body, err := io.ReadAll(io.LimitReader(response.Body, 65537)) + if err != nil { + return nil, err + } + if len(body) > 65536 { + return nil, errors.New("DoH response exceeds 64 KB") + } + return body, nil +} + +func emptyAAAAResponse(query []byte) ([]byte, bool, error) { + if len(query) < 12 || binary.BigEndian.Uint16(query[4:6]) != 1 { + return nil, false, nil + } + offset, err := skipDNSName(query, 12) + if err != nil || offset+4 > len(query) { + return nil, false, errors.New("malformed DNS query") + } + if binary.BigEndian.Uint16(query[offset:offset+2]) != 28 { + return nil, false, nil + } + response := append([]byte(nil), query[:offset+4]...) + requestFlags := binary.BigEndian.Uint16(query[2:4]) + binary.BigEndian.PutUint16(response[2:4], 0x8000|(requestFlags&0x0100)|0x0080) + binary.BigEndian.PutUint16(response[6:8], 0) + binary.BigEndian.PutUint16(response[8:10], 0) + binary.BigEndian.PutUint16(response[10:12], 0) + return response, true, nil +} + +func (c *dohPacketConn) deliver(reply dnsReply) { + select { + case c.replies <- reply: + case <-c.closed: + } +} + +func (c *dohPacketConn) ReadFrom(buffer []byte) (int, net.Addr, error) { + c.deadlineMu.Lock() + deadline := c.readDeadline + c.deadlineMu.Unlock() + var timer <-chan time.Time + if !deadline.IsZero() { + duration := time.Until(deadline) + if duration <= 0 { + return 0, nil, timeoutError{} + } + t := time.NewTimer(duration) + defer t.Stop() + timer = t.C + } + select { + case reply := <-c.replies: + if reply.err != nil { + return 0, reply.addr, reply.err + } + if len(reply.payload) > len(buffer) { + return 0, reply.addr, io.ErrShortBuffer + } + return copy(buffer, reply.payload), reply.addr, nil + case <-timer: + return 0, nil, timeoutError{} + case <-c.closed: + return 0, nil, net.ErrClosed + } +} + +func (c *dohPacketConn) Close() error { + c.closeOnce.Do(func() { + close(c.closed) + c.cancel() + }) + return nil +} +func (c *dohPacketConn) LocalAddr() net.Addr { return dnsAddr("megaproxy-doh") } +func (c *dohPacketConn) SetDeadline(t time.Time) error { + c.deadlineMu.Lock() + c.readDeadline = t + c.deadlineMu.Unlock() + return nil +} +func (c *dohPacketConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) } +func (c *dohPacketConn) SetWriteDeadline(time.Time) error { return nil } + +type dnsAddr string + +func (a dnsAddr) Network() string { return "udp" } +func (a dnsAddr) String() string { return string(a) } + +type timeoutError struct{} + +func (timeoutError) Error() string { return "DNS read deadline exceeded" } +func (timeoutError) Timeout() bool { return true } +func (timeoutError) Temporary() bool { return true } diff --git a/native/mobile/doh_test.go b/native/mobile/doh_test.go new file mode 100644 index 0000000..05ac58f --- /dev/null +++ b/native/mobile/doh_test.go @@ -0,0 +1,74 @@ +package mobile + +import ( + "context" + "encoding/binary" + "net" + "testing" +) + +func TestEmptyAAAAResponseSuppressesIPv6Answer(t *testing.T) { + query, id, err := buildAQuery("example.com") + if err != nil { + t.Fatal(err) + } + binary.BigEndian.PutUint16(query[len(query)-4:len(query)-2], 28) + + response, isAAAA, err := emptyAAAAResponse(query) + if err != nil { + t.Fatal(err) + } + if !isAAAA { + t.Fatal("expected an AAAA query") + } + if binary.BigEndian.Uint16(response[0:2]) != id || binary.BigEndian.Uint16(response[6:8]) != 0 { + t.Fatalf("unexpected empty response: %x", response) + } + if binary.BigEndian.Uint16(response[2:4])&0x8000 == 0 { + t.Fatal("expected the DNS response bit") + } +} + +func TestEmptyAAAAResponseLeavesAQueryUntouched(t *testing.T) { + query, _, err := buildAQuery("example.com") + if err != nil { + t.Fatal(err) + } + response, isAAAA, err := emptyAAAAResponse(query) + if err != nil || isAAAA || response != nil { + t.Fatalf("unexpected A-query result: response=%x isAAAA=%v err=%v", response, isAAAA, err) + } +} + +func TestDoHEndpointOrderStartsWithLastSuccessfulProvider(t *testing.T) { + c := newDoHPacketConnWithClient( + config{DoHFallbackURLs: []string{"https://two.example/dns-query", "https://three.example/dns-query"}}, + nil, + func(context.Context, string) (net.Conn, error) { return nil, nil }, + "https://one.example/dns-query", + nil, + nil, + ) + defer c.Close() + c.setActiveEndpoint(1) + + order := c.endpointOrder() + if len(order) != 3 || order[0].index != 1 || order[1].index != 2 || order[2].index != 0 { + t.Fatalf("unexpected endpoint order: %#v", order) + } +} + +func TestDoHEndpointsIgnoreDuplicatePrimary(t *testing.T) { + c := newDoHPacketConnWithClient( + config{DoHFallbackURLs: []string{"https://one.example/dns-query", "https://two.example/dns-query"}}, + nil, + func(context.Context, string) (net.Conn, error) { return nil, nil }, + "https://one.example/dns-query", + nil, + nil, + ) + defer c.Close() + if len(c.endpoints) != 2 { + t.Fatalf("expected two unique endpoints, got %#v", c.endpoints) + } +} diff --git a/native/mobile/http2_connect.go b/native/mobile/http2_connect.go new file mode 100644 index 0000000..2cff3c5 --- /dev/null +++ b/native/mobile/http2_connect.go @@ -0,0 +1,224 @@ +package mobile + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + "sync" + "time" + + "golang.org/x/net/http2" +) + +type http2ClientConn interface { + RoundTrip(*http.Request) (*http.Response, error) + CanTakeNewRequest() bool + Close() error +} + +type http2ConnectSession struct { + client http2ClientConn + raw net.Conn +} + +func newHTTP2ConnectSession(raw net.Conn) (*http2ConnectSession, error) { + transport := &http2.Transport{ + ReadIdleTimeout: 45 * time.Second, + PingTimeout: 10 * time.Second, + } + client, err := transport.NewClientConn(raw) + if err != nil { + return nil, err + } + return &http2ConnectSession{client: client, raw: raw}, nil +} + +func (s *http2ConnectSession) canTakeRequest() bool { + return s != nil && s.client != nil && s.client.CanTakeNewRequest() +} + +func (s *http2ConnectSession) close() error { + if s == nil { + return nil + } + if s.client != nil { + _ = s.client.Close() + } + if s.raw != nil { + return s.raw.Close() + } + return nil +} + +func (s *http2ConnectSession) openTunnel(ctx context.Context, target, authorization string) (net.Conn, int, error) { + reader, writer := io.Pipe() + streamContext, cancelStream := context.WithCancel(context.Background()) + request := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Scheme: "https", Host: target}, + Host: target, + Header: make(http.Header), + Body: reader, + ContentLength: -1, + } + request = request.WithContext(streamContext) + request.Header.Set("Proxy-Authorization", authorization) + + type result struct { + response *http.Response + err error + } + resultChannel := make(chan result, 1) + go func() { + response, err := s.client.RoundTrip(request) + select { + case resultChannel <- result{response: response, err: err}: + case <-streamContext.Done(): + if response != nil { + _ = response.Body.Close() + } + } + }() + + var outcome result + select { + case outcome = <-resultChannel: + case <-ctx.Done(): + cancelStream() + _ = writer.CloseWithError(ctx.Err()) + _ = reader.CloseWithError(ctx.Err()) + return nil, 0, ctx.Err() + } + if outcome.err != nil { + cancelStream() + _ = writer.CloseWithError(outcome.err) + _ = reader.CloseWithError(outcome.err) + return nil, 0, outcome.err + } + if outcome.response.StatusCode != http.StatusOK { + _ = outcome.response.Body.Close() + cancelStream() + _ = writer.Close() + _ = reader.Close() + return nil, outcome.response.StatusCode, errors.New("HTTP/2 proxy rejected CONNECT") + } + return newHTTP2StreamConn(s.raw, outcome.response.Body, writer, cancelStream), outcome.response.StatusCode, nil +} + +// http2StreamConn exposes one HTTP/2 CONNECT stream as a net.Conn. A deadline +// closes only this stream, never the shared outer TLS connection. +type http2StreamConn struct { + raw net.Conn + reader io.ReadCloser + writer *io.PipeWriter + cancel context.CancelFunc + closeOnce sync.Once + deadlineMu sync.Mutex + readTimer *time.Timer + writeTimer *time.Timer + readExpired bool + writeExpired bool +} + +func newHTTP2StreamConn(raw net.Conn, reader io.ReadCloser, writer *io.PipeWriter, cancel context.CancelFunc) *http2StreamConn { + return &http2StreamConn{raw: raw, reader: reader, writer: writer, cancel: cancel} +} + +func (c *http2StreamConn) Read(p []byte) (int, error) { + n, err := c.reader.Read(p) + c.deadlineMu.Lock() + expired := c.readExpired + c.deadlineMu.Unlock() + if expired && err != nil { + return n, os.ErrDeadlineExceeded + } + return n, err +} + +func (c *http2StreamConn) Write(p []byte) (int, error) { + n, err := c.writer.Write(p) + c.deadlineMu.Lock() + expired := c.writeExpired + c.deadlineMu.Unlock() + if expired && err != nil { + return n, os.ErrDeadlineExceeded + } + return n, err +} + +func (c *http2StreamConn) Close() error { + var closeErr error + c.closeOnce.Do(func() { + c.deadlineMu.Lock() + if c.readTimer != nil { + c.readTimer.Stop() + } + if c.writeTimer != nil { + c.writeTimer.Stop() + } + c.deadlineMu.Unlock() + c.cancel() + _ = c.writer.Close() + closeErr = c.reader.Close() + }) + return closeErr +} + +func (c *http2StreamConn) LocalAddr() net.Addr { return c.raw.LocalAddr() } +func (c *http2StreamConn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() } + +func (c *http2StreamConn) SetDeadline(deadline time.Time) error { + c.setReadDeadline(deadline) + c.setWriteDeadline(deadline) + return nil +} + +func (c *http2StreamConn) SetReadDeadline(deadline time.Time) error { + c.setReadDeadline(deadline) + return nil +} + +func (c *http2StreamConn) SetWriteDeadline(deadline time.Time) error { + c.setWriteDeadline(deadline) + return nil +} + +func (c *http2StreamConn) setReadDeadline(deadline time.Time) { + c.deadlineMu.Lock() + defer c.deadlineMu.Unlock() + c.readExpired = false + if c.readTimer != nil { + c.readTimer.Stop() + c.readTimer = nil + } + if !deadline.IsZero() { + c.readTimer = time.AfterFunc(time.Until(deadline), func() { + c.deadlineMu.Lock() + c.readExpired = true + c.deadlineMu.Unlock() + _ = c.Close() + }) + } +} + +func (c *http2StreamConn) setWriteDeadline(deadline time.Time) { + c.deadlineMu.Lock() + defer c.deadlineMu.Unlock() + c.writeExpired = false + if c.writeTimer != nil { + c.writeTimer.Stop() + c.writeTimer = nil + } + if !deadline.IsZero() { + c.writeTimer = time.AfterFunc(time.Until(deadline), func() { + c.deadlineMu.Lock() + c.writeExpired = true + c.deadlineMu.Unlock() + _ = c.Close() + }) + } +} diff --git a/native/mobile/http2_connect_test.go b/native/mobile/http2_connect_test.go new file mode 100644 index 0000000..fde231c --- /dev/null +++ b/native/mobile/http2_connect_test.go @@ -0,0 +1,125 @@ +package mobile + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func TestHTTP2ConnectSessionMultiplexesStreams(t *testing.T) { + var mu sync.Mutex + authorities := make([]string, 0, 2) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + t.Errorf("method = %q, want CONNECT", r.Method) + return + } + if r.Header.Get("Proxy-Authorization") != "Basic test" { + t.Errorf("authorization was not forwarded") + } + mu.Lock() + authorities = append(authorities, r.Host) + mu.Unlock() + controller := http.NewResponseController(w) + if err := controller.EnableFullDuplex(); err != nil { + t.Errorf("enable full duplex: %v", err) + return + } + w.WriteHeader(http.StatusOK) + _ = controller.Flush() + buffer := make([]byte, 1024) + for { + count, err := r.Body.Read(buffer) + if count > 0 { + _, _ = w.Write(buffer[:count]) + _ = controller.Flush() + } + if err != nil { + return + } + } + })) + server.EnableHTTP2 = true + server.StartTLS() + defer server.Close() + + raw, err := tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{ + InsecureSkipVerify: true, // Test server certificate. + NextProtos: []string{"h2"}, + }) + if err != nil { + t.Fatal(err) + } + session, err := newHTTP2ConnectSession(raw) + if err != nil { + t.Fatal(err) + } + defer session.close() + + for index := 0; index < 2; index++ { + target := fmt.Sprintf("target-%d.example:443", index) + stream, status, err := session.openTunnel(context.Background(), target, "Basic test") + if err != nil || status != http.StatusOK { + t.Fatalf("open stream %d: status=%d err=%v", index, status, err) + } + payload := []byte(fmt.Sprintf("stream-%d", index)) + if _, err := stream.Write(payload); err != nil { + t.Fatal(err) + } + if err := stream.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + response := make([]byte, len(payload)) + if _, err := io.ReadFull(stream, response); err != nil { + t.Fatal(err) + } + if string(response) != string(payload) { + t.Fatalf("response = %q, want %q", response, payload) + } + _ = stream.Close() + } + + mu.Lock() + defer mu.Unlock() + if len(authorities) != 2 || authorities[0] != "target-0.example:443" || authorities[1] != "target-1.example:443" { + t.Fatalf("unexpected CONNECT authorities: %#v", authorities) + } +} + +func TestHTTP2StreamDeadlineDoesNotCloseSharedConnection(t *testing.T) { + left, right := net.Pipe() + defer left.Close() + defer right.Close() + reader, writer := io.Pipe() + stream := newHTTP2StreamConn(left, io.NopCloser(reader), writer, func() {}) + if err := stream.SetReadDeadline(time.Now().Add(10 * time.Millisecond)); err != nil { + t.Fatal(err) + } + buffer := make([]byte, 1) + if _, err := stream.Read(buffer); err == nil || !isTimeout(err) { + t.Fatalf("Read error = %v, want timeout", err) + } + rawRead := make(chan error, 1) + go func() { + _, err := left.Read(buffer) + rawRead <- err + }() + if _, err := right.Write([]byte{1}); err != nil { + t.Fatalf("shared raw connection was closed by stream deadline: %v", err) + } + if err := <-rawRead; err != nil { + t.Fatalf("shared raw connection read failed: %v", err) + } +} + +func isTimeout(err error) bool { + value, ok := err.(interface{ Timeout() bool }) + return ok && value.Timeout() +} diff --git a/native/mobile/ja3.go b/native/mobile/ja3.go new file mode 100644 index 0000000..25d7f47 --- /dev/null +++ b/native/mobile/ja3.go @@ -0,0 +1,61 @@ +package mobile + +import ( + "fmt" + + tls "github.com/refraction-networking/utls" +) + +// applyJA3 builds the extension payloads needed for a usable handshake. JA3 identifies +// extension IDs but not their payloads, so unknown IDs are rejected instead of silently +// producing a fingerprint different from the requested one. +func applyJA3(conn *tls.UConn, raw, serverName string) error { + spec, err := parseJA3(raw) + if err != nil { + return err + } + extensions := make([]tls.TLSExtension, 0, len(spec.Extensions)) + for _, id := range spec.Extensions { + var extension tls.TLSExtension + switch id { + case 0: + extension = &tls.SNIExtension{ServerName: serverName} + case 5: + extension = &tls.StatusRequestExtension{} + case 10: + curves := make([]tls.CurveID, len(spec.Groups)) + for i, group := range spec.Groups { + curves[i] = tls.CurveID(group) + } + extension = &tls.SupportedCurvesExtension{Curves: curves} + case 11: + extension = &tls.SupportedPointsExtension{SupportedPoints: spec.Points} + case 13: + extension = &tls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{tls.ECDSAWithP256AndSHA256, tls.PSSWithSHA256, tls.PKCS1WithSHA256, tls.ECDSAWithP384AndSHA384, tls.PSSWithSHA384}} + case 16: + extension = &tls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}} + case 18: + extension = &tls.SCTExtension{} + case 23: + extension = &tls.UtlsExtendedMasterSecretExtension{} + case 43: + extension = &tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}} + case 45: + extension = &tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}} + case 51: + extension = &tls.KeyShareExtension{KeyShares: []tls.KeyShare{{Group: tls.X25519}}} + case 65281: + extension = &tls.RenegotiationInfoExtension{} + default: + return fmt.Errorf("manual JA3 extension %d needs an explicit payload and is not supported", id) + } + extensions = append(extensions, extension) + } + return conn.ApplyPreset(&tls.ClientHelloSpec{ + TLSVersMin: tls.VersionTLS12, + TLSVersMax: spec.Version, + CipherSuites: spec.Ciphers, + CompressionMethods: []uint8{0}, + Extensions: extensions, + }) +} diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go new file mode 100644 index 0000000..53b33c7 --- /dev/null +++ b/native/mobile/mobile.go @@ -0,0 +1,129 @@ +// Package mobile exposes the gomobile-compatible Android API. +package mobile + +import ( + "context" + "errors" + "io" + "strconv" + "sync" + "syscall" + "time" + + "github.com/xjasonlyu/tun2socks/v2/core" + "github.com/xjasonlyu/tun2socks/v2/core/device" + "github.com/xjasonlyu/tun2socks/v2/core/device/fdbased" + "github.com/xjasonlyu/tun2socks/v2/tunnel" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +var state struct { + sync.Mutex + generation uint64 + starting bool + running bool + device device.Device + stack *stack.Stack + proxyCloser io.Closer +} + +// Start takes ownership of a duplicate of tunFD held by Android's ParcelFileDescriptor. +func Start(tunFD int, rawConfig string, protector Protector, reporter Reporter) error { + c, err := parseConfig(rawConfig) + if err != nil { + if tunFD >= 0 { + _ = syscall.Close(tunFD) + } + return err + } + if tunFD < 0 || protector == nil { + if tunFD >= 0 { + _ = syscall.Close(tunFD) + } + return errors.New("invalid Android VPN bridge") + } + state.Lock() + if state.running || state.starting { + state.Unlock() + _ = syscall.Close(tunFD) + return errors.New("proxy core is already running") + } + state.generation++ + generation := state.generation + state.starting = true + state.Unlock() + committed := false + defer func() { + if committed { + return + } + state.Lock() + state.starting = false + state.Unlock() + }() + resetStats() + dev, err := fdbased.Open(strconv.Itoa(tunFD), 1500, 0) + if err != nil { + _ = syscall.Close(tunFD) + return err + } + t := tunnel.T() + var proxyCloser io.Closer + if c.Type == "HTTPS" { + httpsProxy := &httpsConnectDialer{config: c, protector: protector, reporter: reporter} + t.SetProxy(httpsProxy) + proxyCloser = httpsProxy + } else { + sshProxy := &sshDialer{config: c, protector: protector, reporter: reporter} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + _, sessionErr := sshProxy.session(ctx) + cancel() + if sessionErr != nil { + dev.Close() + report(reporter, "event=ssh_session result=failed detail=%s", sessionErr) + return sessionErr + } + t.SetProxy(sshProxy) + proxyCloser = sshProxy + } + netstack, err := core.CreateStack(&core.Config{LinkEndpoint: dev, TransportHandler: t}) + if err != nil { + dev.Close() + return err + } + state.Lock() + if state.generation != generation || !state.starting { + state.Unlock() + netstack.Close() + netstack.Wait() + if proxyCloser != nil { + _ = proxyCloser.Close() + } + return errors.New("proxy core start was superseded") + } + state.device, state.stack, state.proxyCloser = dev, netstack, proxyCloser + state.starting, state.running = false, true + committed = true + state.Unlock() + report(reporter, "event=native_stack result=started type=%s fingerprint=%s ssh_profile=%s ipv6=%t bypass_local=%t", c.Type, c.Profile, c.SSHProfile, c.AllowIPv6, c.BypassLocalNetworks) + return nil +} + +func Stop() { + state.Lock() + state.generation++ + state.running = false + dev, netstack, proxyCloser := state.device, state.stack, state.proxyCloser + state.device, state.stack, state.proxyCloser = nil, nil, nil + state.Unlock() + if dev != nil { + dev.Close() + } + if netstack != nil { + netstack.Close() + netstack.Wait() + } + if proxyCloser != nil { + _ = proxyCloser.Close() + } +} diff --git a/native/mobile/ssh_dialer.go b/native/mobile/ssh_dialer.go new file mode 100644 index 0000000..c37451b --- /dev/null +++ b/native/mobile/ssh_dialer.go @@ -0,0 +1,462 @@ +package mobile + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + M "github.com/xjasonlyu/tun2socks/v2/metadata" + "golang.org/x/crypto/ssh" +) + +// sshDialer implements OpenSSH-style dynamic forwarding. SOCKS CONNECT semantics +// are mapped directly to SSH direct-tcpip channels; SSH has no general UDP relay. +type sshDialer struct { + config config + protector Protector + reporter Reporter + mu sync.Mutex + client *ssh.Client + jumpClient *ssh.Client + channels chan struct{} + sessionCreated time.Time + sessionBytes atomic.Uint64 + keepaliveStop chan struct{} + dohClient *http.Client + dohInFlight chan struct{} +} + +func (d *sshDialer) DialContext(ctx context.Context, metadata *M.Metadata) (net.Conn, error) { + return d.connectTarget(ctx, metadata.DestinationAddress()) +} + +func (d *sshDialer) connectTarget(ctx context.Context, target string) (net.Conn, error) { + if !d.config.AllowIPv6 { + host, _, _ := net.SplitHostPort(target) + if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { + return nil, errors.New("IPv6 destination blocked by IPv4-only mode") + } + } + if d.config.BypassLocalNetworks && isLocalNetworkTarget(target) { + return d.protectedDialer().DialContext(ctx, "tcp", target) + } + if d.rotationDue() { + d.invalidate() + } + d.mu.Lock() + if d.channels == nil { + d.channels = make(chan struct{}, d.config.SSHMaxChannels) + } + channels := d.channels + d.mu.Unlock() + select { + case channels <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + release := true + defer func() { + if release { + <-channels + } + }() + client, err := d.session(ctx) + if err != nil { + recordConnectionOutcome(false) + report(d.reporter, "event=ssh_session result=failed detail=%s", err) + return nil, err + } + started := time.Now() + dialContext, cancelDial := context.WithTimeout(ctx, 20*time.Second) + conn, err := client.DialContext(dialContext, "tcp", target) + cancelDial() + if err != nil { + d.invalidate() + report(d.reporter, "event=connection mode=ssh stage=direct_tcpip result=failed reason=%s", errorClass(err)) + recordConnectionOutcome(false) + return nil, fmt.Errorf("SSH direct-tcpip: %w", err) + } + recordConnectionOutcome(true) + report(d.reporter, "event=connection mode=ssh stage=direct_tcpip result=success elapsed_ms=%d", time.Since(started).Milliseconds()) + release = false + tracked := &sshTrackedConn{Conn: conn, release: func() { <-channels }, bytes: &d.sessionBytes} + return &diagnosticConn{Conn: tracked, connectionID: nextDiagnosticConnectionID(), reporter: d.reporter}, nil +} + +func (d *sshDialer) session(ctx context.Context) (*ssh.Client, error) { + d.mu.Lock() + defer d.mu.Unlock() + if d.client != nil { + return d.client, nil + } + if d.config.Type == "SSH_JUMP" { + started := time.Now() + raw, err := d.protectedDialer().DialContext(ctx, "tcp", d.config.jumpAddress()) + if err != nil { + report(d.reporter, "event=ssh_transport hop=jump stage=tcp_connect result=failed reason=%s elapsed_ms=%d", errorClass(err), time.Since(started).Milliseconds()) + return nil, fmt.Errorf("dial jump host: %w", err) + } + report(d.reporter, "event=ssh_transport hop=jump stage=tcp_connect result=success elapsed_ms=%d", time.Since(started).Milliseconds()) + jump, err := newSSHClient(ctx, raw, d.config.JumpHost, d.config.JumpUsername, d.config.JumpPassword, d.config.JumpPrivateKey, d.config.JumpTrustedHostKey, d.config.JumpAcceptAnyHostKey, d.config.SSHProfile, d.config.SSHAuthMode, "jump", d.reporter) + if err != nil { + raw.Close() + return nil, err + } + started = time.Now() + nested, err := jump.DialContext(ctx, "tcp", d.config.address()) + if err != nil { + jump.Close() + report(d.reporter, "event=ssh_transport hop=destination stage=direct_tcpip result=failed reason=%s elapsed_ms=%d", errorClass(err), time.Since(started).Milliseconds()) + return nil, fmt.Errorf("jump host could not reach destination SSH host: %w", err) + } + report(d.reporter, "event=ssh_transport hop=destination stage=direct_tcpip result=success elapsed_ms=%d", time.Since(started).Milliseconds()) + client, err := newSSHClient(ctx, nested, d.config.Host, d.config.Username, d.config.Password, d.config.PrivateKey, d.config.TrustedHostKey, d.config.AcceptAnyHostKey, d.config.SSHProfile, d.config.SSHAuthMode, "destination", d.reporter) + if err != nil { + nested.Close() + jump.Close() + return nil, err + } + d.jumpClient, d.client = jump, client + } else { + started := time.Now() + raw, err := d.protectedDialer().DialContext(ctx, "tcp", d.config.address()) + if err != nil { + report(d.reporter, "event=ssh_transport hop=destination stage=tcp_connect result=failed reason=%s elapsed_ms=%d", errorClass(err), time.Since(started).Milliseconds()) + return nil, fmt.Errorf("dial SSH host: %w", err) + } + report(d.reporter, "event=ssh_transport hop=destination stage=tcp_connect result=success elapsed_ms=%d", time.Since(started).Milliseconds()) + client, err := newSSHClient(ctx, raw, d.config.Host, d.config.Username, d.config.Password, d.config.PrivateKey, d.config.TrustedHostKey, d.config.AcceptAnyHostKey, d.config.SSHProfile, d.config.SSHAuthMode, "destination", d.reporter) + if err != nil { + raw.Close() + return nil, err + } + d.client = client + } + report(d.reporter, "event=ssh_session result=established type=%s profile=%s", d.config.Type, d.config.SSHProfile) + d.sessionCreated = time.Now() + d.sessionBytes.Store(0) + d.startKeepalive() + return d.client, nil +} + +func newSSHClient(ctx context.Context, conn net.Conn, hostname, username, password, privateKey, trusted string, acceptAny bool, profile, authMode, hop string, reporter Reporter) (*ssh.Client, error) { + auth, err := sshAuthMethods(privateKey, password, authMode) + if err != nil { + return nil, err + } + cfg := &ssh.ClientConfig{User: username, Auth: auth, HostKeyCallback: hostKeyCallback(trusted, acceptAny, hop), Timeout: 20 * time.Second} + applySSHProfile(cfg, profile) + type handshakeResult struct { + conn ssh.Conn + channels <-chan ssh.NewChannel + requests <-chan *ssh.Request + err error + } + result := make(chan handshakeResult, 1) + observed := &sshResponseObserver{Conn: conn, reporter: reporter, hop: hop, started: time.Now()} + go func() { + cc, channels, requests, err := ssh.NewClientConn(observed, net.JoinHostPort(hostname, "22"), cfg) + result <- handshakeResult{conn: cc, channels: channels, requests: requests, err: err} + }() + timer := time.NewTimer(20 * time.Second) + defer timer.Stop() + var handshake handshakeResult + select { + case handshake = <-result: + case <-ctx.Done(): + conn.Close() + <-result + return nil, fmt.Errorf("SSH %s handshake: %w", hop, ctx.Err()) + case <-timer.C: + conn.Close() + <-result + report(reporter, "event=ssh_transport hop=%s stage=handshake_timeout result=failed read_bytes=%d written_bytes=%d", hop, observed.readBytes.Load(), observed.writtenBytes.Load()) + if observed.received.Load() { + return nil, fmt.Errorf("SSH %s handshake: timeout after server response", hop) + } + return nil, fmt.Errorf("SSH %s handshake: timeout before server response", hop) + } + cc, channels, requests, err := handshake.conn, handshake.channels, handshake.requests, handshake.err + if err != nil { + return nil, fmt.Errorf("SSH %s handshake: %w", hop, err) + } + report(reporter, "event=ssh_handshake hop=%s result=success profile=%s", hop, profile) + report(reporter, "event=transport_capability transport=ssh hop=%s client_family=%s server_family=%s direct_tcpip=true multiplexed=true rekey_mb=%d", hop, sshClientFamily(profile), sshServerFamily(string(cc.ServerVersion())), sshRekeyBytes/(1024*1024)) + return ssh.NewClient(cc, channels, requests), nil +} + +type sshResponseObserver struct { + net.Conn + reporter Reporter + hop string + started time.Time + received atomic.Bool + readBytes atomic.Uint64 + writtenBytes atomic.Uint64 + serverBanner atomic.Bool + serverBinary atomic.Bool + clientBanner atomic.Bool + clientBinary atomic.Bool +} + +func (c *sshResponseObserver) Read(p []byte) (int, error) { + n, err := c.Conn.Read(p) + if n > 0 { + c.readBytes.Add(uint64(n)) + if c.received.CompareAndSwap(false, true) { + report(c.reporter, "event=ssh_transport hop=%s stage=server_response result=received elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds()) + } + payload := p[:n] + if !c.serverBanner.Load() { + if newline := bytes.IndexByte(payload, '\n'); newline >= 0 && c.serverBanner.CompareAndSwap(false, true) { + report(c.reporter, "event=ssh_transport hop=%s stage=server_banner result=received elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds()) + if newline+1 < len(payload) && c.serverBinary.CompareAndSwap(false, true) { + report(c.reporter, "event=ssh_transport hop=%s stage=server_binary_packet result=received elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds()) + } + } + } else if c.serverBinary.CompareAndSwap(false, true) { + report(c.reporter, "event=ssh_transport hop=%s stage=server_binary_packet result=received elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds()) + } + } + return n, err +} + +func (c *sshResponseObserver) Write(p []byte) (int, error) { + n, err := c.Conn.Write(p) + if n > 0 { + c.writtenBytes.Add(uint64(n)) + payload := p[:n] + if !c.clientBanner.Load() && bytes.HasPrefix(payload, []byte("SSH-")) && c.clientBanner.CompareAndSwap(false, true) { + report(c.reporter, "event=ssh_transport hop=%s stage=client_banner result=sent elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds()) + if newline := bytes.IndexByte(payload, '\n'); newline >= 0 && newline+1 < len(payload) && c.clientBinary.CompareAndSwap(false, true) { + report(c.reporter, "event=ssh_transport hop=%s stage=client_binary_packet result=sent elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds()) + } + } else if c.clientBanner.Load() && c.clientBinary.CompareAndSwap(false, true) { + report(c.reporter, "event=ssh_transport hop=%s stage=client_binary_packet result=sent elapsed_ms=%d", c.hop, time.Since(c.started).Milliseconds()) + } + } + return n, err +} + +func sshAuthMethods(privateKey, password, authMode string) ([]ssh.AuthMethod, error) { + methods := make([]ssh.AuthMethod, 0, 2) + if authMode != "PASSWORD_ONLY" && strings.TrimSpace(privateKey) != "" { + signer, err := ssh.ParsePrivateKey([]byte(privateKey)) + if err != nil { + var passphraseErr *ssh.PassphraseMissingError + if errors.As(err, &passphraseErr) { + return nil, errors.New("passphrase-protected SSH private keys are not supported") + } + return nil, fmt.Errorf("invalid SSH private key: %w", err) + } + methods = append(methods, ssh.PublicKeys(signer)) + } + if authMode != "KEY_ONLY" && password != "" { + methods = append(methods, ssh.Password(password)) + methods = append(methods, ssh.KeyboardInteractive(func(_ string, _ string, questions []string, _ []bool) ([]string, error) { + answers := make([]string, len(questions)) + for i := range answers { + answers[i] = password + } + return answers, nil + })) + } + return methods, nil +} + +func hostKeyCallback(trusted string, acceptAny bool, hop string) ssh.HostKeyCallback { + return func(_ string, _ net.Addr, key ssh.PublicKey) error { + fingerprint := ssh.FingerprintSHA256(key) + if acceptAny || trusted == fingerprint { + return nil + } + if trusted == "" { + return fmt.Errorf("SSH_HOST_KEY_UNKNOWN|%s|%s|%s", hop, key.Type(), fingerprint) + } + return fmt.Errorf("SSH_HOST_KEY_CHANGED|%s|%s|%s|%s", hop, key.Type(), trusted, fingerprint) + } +} + +func applySSHProfile(c *ssh.ClientConfig, profile string) { + switch profile { + case "CONNECTBOT": + c.ClientVersion = "SSH-2.0-ConnectBot" + case "JUICESSH": + c.ClientVersion = "SSH-2.0-JuiceSSH" + case "TERMIUS_ANDROID": + c.ClientVersion = "SSH-2.0-Termius" + default: + // OpenSSH 8.4 is a better claim than a newer banner whose mandatory/default + // post-quantum proposal cannot be reproduced byte-for-byte by this stack. + c.ClientVersion = "SSH-2.0-OpenSSH_8.4" + } + // This is the common modern OpenSSH-style order supported by x/crypto. It + // avoids the distinctive Go defaults and weak legacy algorithms while keeping + // compatibility with ordinary servers and dynamic forwarding (`ssh -D`). + c.Config.KeyExchanges = []string{ + ssh.KeyExchangeCurve25519, + ssh.KeyExchangeECDHP256, + ssh.KeyExchangeECDHP384, + ssh.KeyExchangeECDHP521, + ssh.KeyExchangeDHGEXSHA256, + ssh.KeyExchangeDH16SHA512, + ssh.KeyExchangeDH14SHA256, + } + c.Config.Ciphers = []string{"chacha20-poly1305@openssh.com", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr"} + c.Config.MACs = []string{ssh.HMACSHA256ETM, ssh.HMACSHA512ETM, ssh.HMACSHA256, ssh.HMACSHA512} + c.Config.RekeyThreshold = sshRekeyBytes +} + +const sshRekeyBytes = 1 << 30 + +func sshClientFamily(profile string) string { + switch profile { + case "CONNECTBOT": + return "connectbot" + case "JUICESSH": + return "juicessh" + case "TERMIUS_ANDROID": + return "termius" + default: + return "openssh" + } +} + +func sshServerFamily(version string) string { + value := strings.ToLower(version) + switch { + case strings.Contains(value, "openssh"): + return "openssh" + case strings.Contains(value, "dropbear"): + return "dropbear" + default: + return "other" + } +} + +func (d *sshDialer) protectedDialer() *net.Dialer { + return &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second, Control: func(_, _ string, raw syscall.RawConn) error { + var protectErr error + if err := raw.Control(func(fd uintptr) { + if !d.protector.Protect(int(fd)) { + protectErr = errors.New("VpnService.protect rejected upstream socket") + } + }); err != nil { + return err + } + return protectErr + }} +} + +func (d *sshDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, error) { + if metadata.DstPort != 53 { + return nil, errUDPBlocked + } + client, limiter := d.sharedDoHResources() + return newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter), nil +} + +func (d *sshDialer) sharedDoHResources() (*http.Client, chan struct{}) { + d.mu.Lock() + defer d.mu.Unlock() + if d.dohClient == nil { + d.dohClient = newDoHHTTPClient(d.connectTarget) + } + if d.dohInFlight == nil { + d.dohInFlight = make(chan struct{}, 8) + } + return d.dohClient, d.dohInFlight +} + +func (d *sshDialer) invalidate() { + d.mu.Lock() + defer d.mu.Unlock() + if d.client != nil { + d.client.Close() + } + if d.jumpClient != nil { + d.jumpClient.Close() + } + d.client, d.jumpClient = nil, nil + if d.keepaliveStop != nil { + close(d.keepaliveStop) + d.keepaliveStop = nil + } +} + +func (d *sshDialer) Close() error { + d.invalidate() + d.mu.Lock() + if d.dohClient != nil { + d.dohClient.CloseIdleConnections() + d.dohClient = nil + } + d.mu.Unlock() + return nil +} + +func (d *sshDialer) rotationDue() bool { + d.mu.Lock() + defer d.mu.Unlock() + if d.client == nil { + return false + } + if d.channels != nil && len(d.channels) > 0 { + return false + } + if d.config.SSHRotationMinutes > 0 && time.Since(d.sessionCreated) >= time.Duration(d.config.SSHRotationMinutes)*time.Minute { + return true + } + return d.config.SSHRotationMB > 0 && d.sessionBytes.Load() >= uint64(d.config.SSHRotationMB)*1024*1024 +} + +func (d *sshDialer) startKeepalive() { + if d.config.SSHKeepaliveSeconds <= 0 || d.client == nil { + return + } + stop := make(chan struct{}) + d.keepaliveStop = stop + client := d.client + interval := time.Duration(d.config.SSHKeepaliveSeconds) * time.Second + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil { + return + } + case <-stop: + return + } + } + }() +} + +type sshTrackedConn struct { + net.Conn + release func() + bytes *atomic.Uint64 + once sync.Once +} + +func (c *sshTrackedConn) Read(p []byte) (int, error) { + n, err := c.Conn.Read(p) + c.bytes.Add(uint64(n)) + return n, err +} +func (c *sshTrackedConn) Write(p []byte) (int, error) { + n, err := c.Conn.Write(p) + c.bytes.Add(uint64(n)) + return n, err +} +func (c *sshTrackedConn) Close() error { err := c.Conn.Close(); c.once.Do(c.release); return err } diff --git a/native/mobile/ssh_dialer_test.go b/native/mobile/ssh_dialer_test.go new file mode 100644 index 0000000..9280d54 --- /dev/null +++ b/native/mobile/ssh_dialer_test.go @@ -0,0 +1,67 @@ +package mobile + +import ( + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestHostKeyTOFU(t *testing.T) { + public, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + key, err := ssh.NewPublicKey(public) + if err != nil { + t.Fatal(err) + } + fingerprint := ssh.FingerprintSHA256(key) + + err = hostKeyCallback("", false, "jump")("host", nil, key) + if err == nil || !strings.Contains(err.Error(), "SSH_HOST_KEY_UNKNOWN|jump|") || !strings.Contains(err.Error(), fingerprint) { + t.Fatalf("unexpected TOFU error: %v", err) + } + if err := hostKeyCallback(fingerprint, false, "jump")("host", nil, key); err != nil { + t.Fatal(err) + } + if err := hostKeyCallback("different", true, "jump")("host", nil, key); err != nil { + t.Fatal(err) + } +} + +func TestSSHAuthRejectsMalformedPrivateKeyInsteadOfFallingBack(t *testing.T) { + _, err := sshAuthMethods("not a private key", "password", "AUTO") + if err == nil || !strings.Contains(err.Error(), "invalid SSH private key") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSSHPasswordIncludesKeyboardInteractiveFallback(t *testing.T) { + methods, err := sshAuthMethods("", "password", "AUTO") + if err != nil { + t.Fatal(err) + } + if len(methods) != 2 { + t.Fatalf("got %d password auth methods, want password and keyboard-interactive", len(methods)) + } +} + +func TestSSHAuthenticationModes(t *testing.T) { + methods, err := sshAuthMethods("", "password", "KEY_ONLY") + if err != nil { + t.Fatal(err) + } + if len(methods) != 0 { + t.Fatalf("key-only unexpectedly used password: %d methods", len(methods)) + } + methods, err = sshAuthMethods("", "password", "PASSWORD_ONLY") + if err != nil { + t.Fatal(err) + } + if len(methods) != 2 { + t.Fatalf("password-only got %d methods", len(methods)) + } +} diff --git a/native/mobile/ssh_profile_test.go b/native/mobile/ssh_profile_test.go new file mode 100644 index 0000000..5738c72 --- /dev/null +++ b/native/mobile/ssh_profile_test.go @@ -0,0 +1,35 @@ +package mobile + +import ( + "slices" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestOpenSSHProfileUsesOrdinaryDynamicForwardingFingerprint(t *testing.T) { + c := &ssh.ClientConfig{} + applySSHProfile(c, "OPENSSH_TERMUX") + if c.ClientVersion != "SSH-2.0-OpenSSH_8.4" { + t.Fatalf("unexpected client banner %q", c.ClientVersion) + } + if c.RekeyThreshold != sshRekeyBytes { + t.Fatalf("unexpected rekey threshold %d", c.RekeyThreshold) + } + if len(c.KeyExchanges) < 5 || c.KeyExchanges[0] != ssh.KeyExchangeCurve25519 { + t.Fatalf("unexpected KEX order %#v", c.KeyExchanges) + } + if !slices.Contains(c.Ciphers, ssh.CipherAES192CTR) { + t.Fatalf("OpenSSH-compatible AES-192 CTR is missing: %#v", c.Ciphers) + } +} + +func TestSSHProfilesDoNotExposeImplementationName(t *testing.T) { + for _, profile := range []string{"OPENSSH_TERMUX", "CONNECTBOT", "JUICESSH", "TERMIUS_ANDROID"} { + c := &ssh.ClientConfig{} + applySSHProfile(c, profile) + if c.ClientVersion == "" || c.ClientVersion == "SSH-2.0-Go" { + t.Fatalf("profile %s exposes an invalid implementation banner %q", profile, c.ClientVersion) + } + } +} diff --git a/native/mobile/stats.go b/native/mobile/stats.go new file mode 100644 index 0000000..e2f07b8 --- /dev/null +++ b/native/mobile/stats.go @@ -0,0 +1,106 @@ +package mobile + +import ( + "encoding/json" + "sync" + "sync/atomic" + "time" +) + +const connectionSampleWindow = 50 + +type connectionStats struct { + downloadBytes atomic.Uint64 + uploadBytes atomic.Uint64 + totalOutcomes atomic.Uint64 + + mu sync.Mutex + latencyMillis float64 + latencySamples uint64 + latencyAt time.Time + outcomes [connectionSampleWindow]bool + outcomeCount int + outcomeNext int +} + +var telemetry connectionStats + +type statsSnapshot struct { + DownloadBytes uint64 `json:"downloadBytes"` + UploadBytes uint64 `json:"uploadBytes"` + ProxyLatencyMillis float64 `json:"proxyLatencyMillis"` + ProxyLatencyAtMillis int64 `json:"proxyLatencyAtMillis"` + ConnectionErrorRate float64 `json:"connectionErrorRate"` + ConnectionSamples int `json:"connectionSamples"` + TotalOutcomes uint64 `json:"totalOutcomes"` +} + +func resetStats() { + telemetry.downloadBytes.Store(0) + telemetry.uploadBytes.Store(0) + telemetry.totalOutcomes.Store(0) + telemetry.mu.Lock() + telemetry.latencyMillis = 0 + telemetry.latencySamples = 0 + telemetry.latencyAt = time.Time{} + telemetry.outcomes = [connectionSampleWindow]bool{} + telemetry.outcomeCount = 0 + telemetry.outcomeNext = 0 + telemetry.mu.Unlock() +} + +func recordProxyLatency(elapsed time.Duration) { + millis := float64(elapsed.Microseconds()) / 1000 + telemetry.mu.Lock() + if telemetry.latencySamples == 0 { + telemetry.latencyMillis = millis + } else { + const alpha = 0.2 + telemetry.latencyMillis = alpha*millis + (1-alpha)*telemetry.latencyMillis + } + telemetry.latencySamples++ + telemetry.latencyAt = time.Now() + telemetry.mu.Unlock() +} + +func recordConnectionOutcome(success bool) { + telemetry.totalOutcomes.Add(1) + telemetry.mu.Lock() + telemetry.outcomes[telemetry.outcomeNext] = success + telemetry.outcomeNext = (telemetry.outcomeNext + 1) % connectionSampleWindow + if telemetry.outcomeCount < connectionSampleWindow { + telemetry.outcomeCount++ + } + telemetry.mu.Unlock() +} + +func snapshotStats() statsSnapshot { + result := statsSnapshot{ + DownloadBytes: telemetry.downloadBytes.Load(), + UploadBytes: telemetry.uploadBytes.Load(), + TotalOutcomes: telemetry.totalOutcomes.Load(), + } + telemetry.mu.Lock() + result.ProxyLatencyMillis = telemetry.latencyMillis + if !telemetry.latencyAt.IsZero() { + result.ProxyLatencyAtMillis = telemetry.latencyAt.UnixMilli() + } + result.ConnectionSamples = telemetry.outcomeCount + failures := 0 + for index := 0; index < telemetry.outcomeCount; index++ { + if !telemetry.outcomes[index] { + failures++ + } + } + if telemetry.outcomeCount > 0 { + result.ConnectionErrorRate = float64(failures) / float64(telemetry.outcomeCount) + } + telemetry.mu.Unlock() + return result +} + +// GetStats returns a lightweight JSON snapshot for the Android UI. +func GetStats() string { + encoded, _ := json.Marshal(snapshotStats()) + return string(encoded) +} diff --git a/native/mobile/stats_test.go b/native/mobile/stats_test.go new file mode 100644 index 0000000..8c9b5e5 --- /dev/null +++ b/native/mobile/stats_test.go @@ -0,0 +1,41 @@ +package mobile + +import ( + "testing" + "time" +) + +func TestConnectionStatsRollingWindow(t *testing.T) { + resetStats() + for index := 0; index < connectionSampleWindow+10; index++ { + recordConnectionOutcome(index%2 == 0) + } + snapshot := snapshotStats() + if snapshot.ConnectionSamples != connectionSampleWindow { + t.Fatalf("samples = %d, want %d", snapshot.ConnectionSamples, connectionSampleWindow) + } + if snapshot.TotalOutcomes != connectionSampleWindow+10 { + t.Fatalf("total outcomes = %d, want %d", snapshot.TotalOutcomes, connectionSampleWindow+10) + } + if snapshot.ConnectionErrorRate != 0.5 { + t.Fatalf("error rate = %f, want 0.5", snapshot.ConnectionErrorRate) + } +} + +func TestConnectionStatsCountersAndLatency(t *testing.T) { + resetStats() + telemetry.downloadBytes.Add(2048) + telemetry.uploadBytes.Add(1024) + recordProxyLatency(80 * time.Millisecond) + recordProxyLatency(100 * time.Millisecond) + snapshot := snapshotStats() + if snapshot.DownloadBytes != 2048 || snapshot.UploadBytes != 1024 { + t.Fatalf("unexpected counters: %+v", snapshot) + } + if snapshot.ProxyLatencyMillis != 84 { + t.Fatalf("EWMA latency = %f, want 84", snapshot.ProxyLatencyMillis) + } + if snapshot.ProxyLatencyAtMillis == 0 { + t.Fatal("latency timestamp was not recorded") + } +} diff --git a/native/mobile/test_connection.go b/native/mobile/test_connection.go new file mode 100644 index 0000000..f9926ed --- /dev/null +++ b/native/mobile/test_connection.go @@ -0,0 +1,99 @@ +package mobile + +import ( + "bufio" + "context" + stdtls "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +// TestConnection verifies the configured proxy path without starting a TUN device. +func TestConnection(rawConfig string, protector Protector, reporter Reporter) (string, error) { + c, err := parseConfig(rawConfig) + if err != nil { + return "", err + } + var connect func(context.Context, string) (net.Conn, error) + var testReporter Reporter + if c.Type == "HTTPS" { + dialer := &httpsConnectDialer{config: c, protector: protector, reporter: reporter} + connect, testReporter = dialer.connectTarget, dialer.reporter + } else { + dialer := &sshDialer{config: c, protector: protector, reporter: reporter} + connect, testReporter = dialer.connectTarget, dialer.reporter + defer dialer.invalidate() + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + report(reporter, "event=connection_test result=started") + if _, err := testHTTPSGet(ctx, connect, testReporter, "example.com", "/", false); err != nil { + return "", fmt.Errorf("example.com check: %w", err) + } + report(reporter, "event=connection_test stage=https_check result=success") + + ip, err := testHTTPSGet(ctx, connect, testReporter, "ifconfig.me", "/ip", true) + if err != nil { + return "", fmt.Errorf("public IP check: %w", err) + } + ip = strings.TrimSpace(ip) + if ip == "" || len(ip) > 128 { + return "", fmt.Errorf("public IP check returned an invalid response") + } + report(reporter, "event=connection_test stage=exit_ip result=success family=%s", ipFamily(ip)) + return ip, nil +} + +func ipFamily(value string) string { + ip := net.ParseIP(strings.TrimSpace(value)) + if ip == nil { + return "unknown" + } + if ip.To4() != nil { + return "ipv4" + } + return "ipv6" +} + +func testHTTPSGet(ctx context.Context, connect func(context.Context, string) (net.Conn, error), reporter Reporter, host, path string, readBody bool) (string, error) { + tunnel, err := connect(ctx, host+":443") + if err != nil { + return "", err + } + defer tunnel.Close() + + connection := stdtls.Client(tunnel, &stdtls.Config{ServerName: host, MinVersion: stdtls.VersionTLS12}) + if err := connection.HandshakeContext(ctx); err != nil { + return "", fmt.Errorf("destination TLS handshake: %w", err) + } + tlsState := connection.ConnectionState() + report(reporter, "event=connection_test stage=destination_tls result=success certificate=verified version=0x%04x cipher=0x%04x alpn=%s h2_negotiated=%t session_resumed=%t", tlsState.Version, tlsState.CipherSuite, normalizedALPN(tlsState.NegotiatedProtocol), tlsState.NegotiatedProtocol == "h2", tlsState.DidResume) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+path, nil) + if err != nil { + return "", err + } + request.Header.Set("User-Agent", "MegaProxy/0.1 connection-test") + request.Header.Set("Connection", "close") + if err := request.Write(connection); err != nil { + return "", fmt.Errorf("write HTTPS request: %w", err) + } + response, err := http.ReadResponse(bufio.NewReader(connection), request) + if err != nil { + return "", fmt.Errorf("read HTTPS response: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 400 { + return "", fmt.Errorf("HTTPS server returned %s", response.Status) + } + if !readBody { + return "", nil + } + body, err := io.ReadAll(io.LimitReader(response.Body, 256)) + return string(body), err +} diff --git a/scripts/android-jdwp.sh b/scripts/android-jdwp.sh new file mode 100755 index 0000000..e3d3838 --- /dev/null +++ b/scripts/android-jdwp.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +package_name="net.megaproxy487" +component_name="$package_name/.MainActivity" +debug_port="8700" +jdwp_pid_file="${TMPDIR:-/tmp}/megaproxy-jdwp-session.pid" + +adb -e wait-for-device +adb -e shell am force-stop "$package_name" +adb -e shell am set-debug-app -w "$package_name" +adb -e shell am start -D -n "$component_name" >/dev/null + +app_pid="" +for _ in {1..50}; do + app_pid="$(adb -e shell pidof -s "$package_name" 2>/dev/null | tr -d '\r' || true)" + if [[ -n "$app_pid" ]]; then + break + fi + sleep 0.1 +done + +if [[ -z "$app_pid" ]]; then + echo "MegaProxy process did not start" >&2 + exit 1 +fi + +jdwp_snapshot="$(mktemp "${TMPDIR:-/tmp}/megaproxy-jdwp.XXXXXX")" +jdwp_tracker_pid="" +cleanup_jdwp_tracker() { + if [[ -n "$jdwp_tracker_pid" ]]; then + kill "$jdwp_tracker_pid" >/dev/null 2>&1 || true + wait "$jdwp_tracker_pid" >/dev/null 2>&1 || true + fi + rm -f "$jdwp_snapshot" +} +trap cleanup_jdwp_tracker EXIT + +adb -e jdwp >"$jdwp_snapshot" & +jdwp_tracker_pid="$!" + +jdwp_ready=false +for _ in {1..100}; do + if grep -Fxq "$app_pid" "$jdwp_snapshot"; then + jdwp_ready=true + break + fi + sleep 0.1 +done + +if [[ "$jdwp_ready" != "true" ]]; then + echo "Process $app_pid did not register a JDWP endpoint" >&2 + echo "Registered JDWP processes:" >&2 + cat "$jdwp_snapshot" >&2 + exit 1 +fi + +cleanup_jdwp_tracker +trap - EXIT + +adb -e forward --remove "tcp:$debug_port" >/dev/null 2>&1 || true +adb -e forward "tcp:$debug_port" "jdwp:$app_pid" >/dev/null + +forward_entry="$(adb -e forward --list | awk -v port="tcp:$debug_port" '$2 == port { print $0 }')" +if [[ "$forward_entry" != *"jdwp:$app_pid"* ]]; then + echo "ADB did not create the expected JDWP forward" >&2 + adb -e forward --list >&2 + exit 1 +fi + +if [[ "$(adb -e get-state)" != "device" ]]; then + echo "Android emulator disconnected before debugger attach" >&2 + exit 1 +fi + +echo "JDWP ready: net.megaproxy487 pid=$app_pid port=$debug_port" +echo "Forward: $forward_entry" + +if [[ "${MEGAPROXY_JDWP_KEEP_ALIVE:-0}" == "1" ]]; then + printf '%s\n' "$$" >"$jdwp_pid_file" + cleanup_debug_session() { + adb -e forward --remove "tcp:$debug_port" >/dev/null 2>&1 || true + adb -e shell am clear-debug-app >/dev/null 2>&1 || true + if [[ -f "$jdwp_pid_file" ]] && [[ "$(tr -dc '0-9' <"$jdwp_pid_file")" == "$$" ]]; then + rm -f "$jdwp_pid_file" + fi + } + trap cleanup_debug_session EXIT INT TERM + + while adb -e get-state >/dev/null 2>&1; do + sleep 1 + done +fi diff --git a/scripts/android-logcat.sh b/scripts/android-logcat.sh new file mode 100755 index 0000000..372c967 --- /dev/null +++ b/scripts/android-logcat.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +package_name="net.megaproxy487" +adb -e wait-for-device + +app_pid="$(adb -e shell pidof -s "$package_name" 2>/dev/null | tr -d '\r' || true)" +if [[ -z "$app_pid" ]]; then + echo "MegaProxy is not running; launch it before opening app Logcat" >&2 + exit 1 +fi + +exec adb -e logcat --pid="$app_pid" diff --git a/scripts/build-release-apks.sh b/scripts/build-release-apks.sh new file mode 100755 index 0000000..4d84f8f --- /dev/null +++ b/scripts/build-release-apks.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -f "$HOME/.zshrc.extra" ]]; then + # VS Code and non-interactive shells may not inherit the Android toolchain. + source "$HOME/.zshrc.extra" +fi + +: "${JAVA_HOME:=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home}" +: "${ANDROID_HOME:=$HOME/Library/Android/sdk}" +: "${ANDROID_NDK_HOME:=$ANDROID_HOME/ndk/29.0.14206865}" +: "${MEGAPROXY_KEYSTORE_PATH:=$HOME/AndroidApkKey}" +: "${MEGAPROXY_KEY_PASSWORD_FILE:=$HOME/.my-tokens/android-key-password}" +: "${MEGAPROXY_KEY_ALIAS:=key0}" +: "${MEGAPROXY_RELEASE_DIR:=$project_dir/dist/release}" + +export JAVA_HOME ANDROID_HOME ANDROID_NDK_HOME +export PATH="$JAVA_HOME/bin:$HOME/go/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" + +for command in go gomobile java keytool; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command is unavailable: $command" >&2 + exit 1 + fi +done + +if [[ ! -x "$project_dir/gradlew" ]]; then + echo "Gradle wrapper is missing or not executable: $project_dir/gradlew" >&2 + exit 1 +fi +if [[ ! -f "$MEGAPROXY_KEYSTORE_PATH" ]]; then + echo "Release keystore is missing: $MEGAPROXY_KEYSTORE_PATH" >&2 + exit 1 +fi +if [[ ! -f "$MEGAPROXY_KEY_PASSWORD_FILE" ]]; then + echo "Keystore password file is missing: $MEGAPROXY_KEY_PASSWORD_FILE" >&2 + exit 1 +fi +if [[ ! -d "$ANDROID_NDK_HOME" ]]; then + echo "Android NDK is missing: $ANDROID_NDK_HOME" >&2 + exit 1 +fi + +keystore_password="$(<"$MEGAPROXY_KEY_PASSWORD_FILE")" +if [[ -z "$keystore_password" ]]; then + echo "Keystore password file is empty" >&2 + exit 1 +fi +: "${MEGAPROXY_KEY_PASSWORD:=$keystore_password}" + +keytool -list \ + -keystore "$MEGAPROXY_KEYSTORE_PATH" \ + -storepass:file "$MEGAPROXY_KEY_PASSWORD_FILE" \ + -alias "$MEGAPROXY_KEY_ALIAS" >/dev/null + +version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' "$project_dir/app/build.gradle.kts" | head -n 1)" +if [[ -z "$version_name" ]]; then + echo "Unable to determine versionName from app/build.gradle.kts" >&2 + exit 1 +fi + +mkdir -p "$MEGAPROXY_RELEASE_DIR" "$project_dir/app/libs" + +temporary_dir="$(mktemp -d "${TMPDIR:-/tmp}/megaproxy-release.XXXXXX")" +original_aar="$project_dir/app/libs/megaproxy.aar" +had_original_aar=false +if [[ -f "$original_aar" ]]; then + cp "$original_aar" "$temporary_dir/megaproxy.aar" + had_original_aar=true +fi +restore_workspace() { + if [[ "$had_original_aar" == true ]]; then + cp "$temporary_dir/megaproxy.aar" "$original_aar" + else + rm -f "$original_aar" + fi + rm -rf "$temporary_dir" +} +trap restore_workspace EXIT + +export MEGAPROXY_KEYSTORE_PATH +export MEGAPROXY_KEYSTORE_PASSWORD="$keystore_password" +export MEGAPROXY_KEY_ALIAS +export MEGAPROXY_KEY_PASSWORD + +echo "Running native tests" +( + cd "$project_dir/native" + go test ./... +) + +echo "Running Android unit tests" +( + cd "$project_dir" + ./gradlew testReleaseUnitTest +) + +# gomobile architecture names and their corresponding Android ABI names. +targets=( + "arm64:arm64-v8a" + "arm:armeabi-v7a" + "amd64:x86_64" + "386:x86" +) + +for target in "${targets[@]}"; do + go_arch="${target%%:*}" + android_abi="${target##*:}" + output_apk="$MEGAPROXY_RELEASE_DIR/mega-proxy-v${version_name}-${android_abi}.apk" + + echo "Building optimized native AAR for $android_abi" + ( + cd "$project_dir/native" + gomobile bind \ + -target="android/$go_arch" \ + -androidapi 26 \ + -trimpath \ + -ldflags="-s -w -buildid=" \ + -o ../app/libs/megaproxy.aar \ + ./mobile + ) + + echo "Building and signing $android_abi APK" + ( + cd "$project_dir" + ./gradlew clean assembleRelease + ) + built_apk="$project_dir/app/build/outputs/apk/release/app-release.apk" + if [[ ! -f "$built_apk" ]]; then + echo "Gradle did not produce the expected APK: $built_apk" >&2 + exit 1 + fi + cp "$built_apk" "$output_apk" + + apksigner="$ANDROID_HOME/build-tools/35.0.0/apksigner" + if [[ ! -x "$apksigner" ]]; then + apksigner="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner -perm -u+x | sort -V | tail -n 1)" + fi + if [[ -z "$apksigner" || ! -x "$apksigner" ]]; then + echo "Android apksigner is unavailable" >&2 + exit 1 + fi + "$apksigner" verify --verbose --print-certs "$output_apk" >/dev/null +done + +( + cd "$MEGAPROXY_RELEASE_DIR" + shasum -a 256 ./*.apk > SHA256SUMS +) + +echo +echo "Signed release APKs:" +ls -lh "$MEGAPROXY_RELEASE_DIR"/*.apk +echo "Checksums: $MEGAPROXY_RELEASE_DIR/SHA256SUMS" diff --git a/scripts/create-android-emulator.sh b/scripts/create-android-emulator.sh new file mode 100755 index 0000000..7f2de59 --- /dev/null +++ b/scripts/create-android-emulator.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +avd_name="${MEGAPROXY_AVD_NAME:-MegaProxy_API_35}" +system_image="system-images;android-35;google_apis;arm64-v8a" +device_profile="pixel_8" + +if [[ -z "${ANDROID_HOME:-}" ]]; then + echo "ANDROID_HOME is not set" >&2 + exit 1 +fi + +sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" +avdmanager="$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" +emulator="$ANDROID_HOME/emulator/emulator" + +for tool in "$sdkmanager" "$avdmanager"; do + if [[ ! -x "$tool" ]]; then + echo "Required Android SDK tool is missing: $tool" >&2 + exit 1 + fi +done + +configure_avd() { + local avd_config="$HOME/.android/avd/${avd_name}.avd/config.ini" + + if [[ ! -f "$avd_config" ]]; then + echo "Android emulator configuration is missing: $avd_config" >&2 + exit 1 + fi + + set_avd_option() { + local key="$1" + local value="$2" + local temporary_config + temporary_config="$(mktemp "${TMPDIR:-/tmp}/megaproxy-avd-config.XXXXXX")" + + awk -F= -v key="$key" -v value="$value" ' + $1 == key { + if (!updated) { + print key "=" value + updated = 1 + } + next + } + { print } + END { + if (!updated) { + print key "=" value + } + } + ' "$avd_config" >"$temporary_config" + mv "$temporary_config" "$avd_config" + } + + # Integrate the host keyboard and use the mouse as a multi-touch input device. + set_avd_option "hw.keyboard" "yes" + set_avd_option "hw.keyboard.charmap" "qwerty2" + set_avd_option "hw.keyboard.lid" "yes" + set_avd_option "hw.screen" "multi-touch" + + echo "Configured host keyboard and mouse input for: $avd_name" +} + +if [[ ! -x "$emulator" || ! -d "$ANDROID_HOME/system-images/android-35/google_apis/arm64-v8a" ]]; then + "$sdkmanager" \ + --sdk_root="$ANDROID_HOME" \ + "emulator" \ + "$system_image" +fi + +if "$emulator" -list-avds | grep -Fxq "$avd_name"; then + echo "Android emulator already exists: $avd_name" + configure_avd + exit 0 +fi + +echo no | "$avdmanager" create avd \ + --name "$avd_name" \ + --package "$system_image" \ + --device "$device_profile" + +configure_avd + +echo "Created Android emulator: $avd_name" +echo "Start it with: emulator @$avd_name -gpu auto" diff --git a/scripts/debug-android-app.sh b/scripts/debug-android-app.sh new file mode 100755 index 0000000..5183db3 --- /dev/null +++ b/scripts/debug-android-app.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -f "$HOME/.zshrc.extra" ]]; then + source "$HOME/.zshrc.extra" +fi + +echo "Starting MegaProxy JDWP setup" +"$project_dir/scripts/stop-android-app-session.sh" +"$project_dir/scripts/run-android-app.sh" +"$project_dir/scripts/android-jdwp.sh" diff --git a/scripts/run-android-app.sh b/scripts/run-android-app.sh new file mode 100755 index 0000000..24b29b5 --- /dev/null +++ b/scripts/run-android-app.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -f "$HOME/.zshrc.extra" ]]; then + # VS Code launched from Finder may not inherit the interactive shell environment. + source "$HOME/.zshrc.extra" +fi + +"$project_dir/scripts/start-android-emulator.sh" + +mkdir -p "$project_dir/app/libs" +( + cd "$project_dir/native" + gomobile bind \ + -target=android \ + -androidapi 26 \ + -o ../app/libs/megaproxy.aar \ + ./mobile +) + +( + cd "$project_dir" + ./gradlew assembleDebug +) + +adb -e install -r "$project_dir/app/build/outputs/apk/debug/app-debug.apk" +adb -e shell am start -n net.megaproxy487/.MainActivity diff --git a/scripts/run-without-debugging.sh b/scripts/run-without-debugging.sh new file mode 100755 index 0000000..4bcb2b2 --- /dev/null +++ b/scripts/run-without-debugging.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -f "$HOME/.zshrc.extra" ]]; then + # VS Code launched from Finder may not inherit the interactive shell environment. + source "$HOME/.zshrc.extra" +fi + +"$project_dir/scripts/stop-android-app-session.sh" + +exec "$project_dir/scripts/run-android-app.sh" diff --git a/scripts/start-android-emulator.sh b/scripts/start-android-emulator.sh new file mode 100755 index 0000000..356c197 --- /dev/null +++ b/scripts/start-android-emulator.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +avd_name="${MEGAPROXY_AVD_NAME:-MegaProxy_API_35}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +"$script_dir/create-android-emulator.sh" + +avd_processes() { + ps ax -o pid=,command= | awk -v avd="@$avd_name" ' + index($0, avd) && ($0 ~ /\/emulator / || $0 ~ /qemu-system/) { print $1 } + ' +} + +emulator_serial="$(adb devices | awk 'NR > 1 && $1 ~ /^emulator-/ { print $1; exit }')" +emulator_state="" + +if [[ -n "$emulator_serial" ]]; then + emulator_state="$(adb -s "$emulator_serial" get-state 2>/dev/null || true)" +fi + +if [[ -n "$emulator_serial" && "$emulator_state" != "device" ]]; then + echo "Android emulator is $emulator_state; attempting ADB reconnect" + adb reconnect offline >/dev/null 2>&1 || true + for _ in {1..20}; do + emulator_state="$(adb -s "$emulator_serial" get-state 2>/dev/null || true)" + [[ "$emulator_state" == "device" ]] && break + sleep 0.25 + done +fi + +if { [[ -n "$emulator_serial" && "$emulator_state" != "device" ]] || + { [[ -z "$emulator_serial" ]] && [[ -n "$(avd_processes)" ]]; }; }; then + if [[ -n "$emulator_serial" ]]; then + echo "Stopping unresponsive emulator: $emulator_serial" + adb -s "$emulator_serial" emu kill >/dev/null 2>&1 || true + else + echo "Stopping orphaned emulator process for: $avd_name" + fi + while read -r emulator_pid; do + if [[ "$emulator_pid" =~ ^[0-9]+$ ]]; then + kill -TERM "$emulator_pid" >/dev/null 2>&1 || true + fi + done < <(avd_processes) + for _ in {1..120}; do + [[ -z "$(avd_processes)" ]] && break + sleep 0.25 + done + while read -r emulator_pid; do + if [[ "$emulator_pid" =~ ^[0-9]+$ ]]; then + echo "Force-stopping stale emulator process: $emulator_pid" + kill -KILL "$emulator_pid" >/dev/null 2>&1 || true + fi + done < <(avd_processes) + sleep 1 + emulator_serial="" +fi + +if [[ -n "$emulator_serial" && "$emulator_state" == "device" ]]; then + echo "An Android emulator is already running" +else + emulator_log="${TMPDIR:-/tmp}/megaproxy-emulator.log" + echo "Starting Android emulator: $avd_name" + # Quick Boot snapshots can leave the guest visible but permanently offline in ADB. + # Prefer a deterministic cold boot for automated run and debug workflows. + # Emulator 37.1.11 can crash its host-side netsimd process during startup on + # macOS. MegaProxy does not need simulated Wi-Fi/UWB radio packet exchange; + # Android's regular virtual network remains available with these disabled. + emulator_options=( + "@$avd_name" + -gpu auto + -no-snapshot-load + -no-snapshot-save + -feature -WiFiPacketStream + -feature -Uwb + -feature -Nfc + -netsim-args "--no-test-beacons --no-cli-ui --no-web-ui" + ) + nohup emulator "${emulator_options[@]}" "$emulator_log" 2>&1 & +fi + +echo "Waiting for Android emulator transport" +emulator_online=false +for _ in {1..120}; do + if [[ "$(adb -e get-state 2>/dev/null || true)" == "device" ]]; then + emulator_online=true + break + fi + sleep 1 +done + +if [[ "$emulator_online" != "true" ]]; then + echo "Android emulator did not become available in ADB within 120 seconds" >&2 + echo "Emulator log: ${emulator_log:-${TMPDIR:-/tmp}/megaproxy-emulator.log}" >&2 + exit 1 +fi + +echo "Waiting for Android to finish booting" +for _ in {1..180}; do + boot_completed="$(adb -e shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + if [[ "$boot_completed" == "1" ]]; then + echo "Android emulator is ready" + exit 0 + fi + sleep 1 +done + +echo "Android emulator did not finish booting within 180 seconds" >&2 +exit 1 diff --git a/scripts/stop-android-app-session.sh b/scripts/stop-android-app-session.sh new file mode 100755 index 0000000..6cbb898 --- /dev/null +++ b/scripts/stop-android-app-session.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +package_name="net.megaproxy487" +debug_port="8700" +jdwp_pid_file="${TMPDIR:-/tmp}/megaproxy-jdwp-session.pid" + +if [[ -f "$jdwp_pid_file" ]]; then + jdwp_session_pid="$(tr -dc '0-9' <"$jdwp_pid_file")" + jdwp_session_command="$(ps -p "$jdwp_session_pid" -o command= 2>/dev/null || true)" + if [[ -n "$jdwp_session_pid" ]] && [[ "$jdwp_session_command" == *"android-jdwp.sh"* ]] && kill -0 "$jdwp_session_pid" >/dev/null 2>&1; then + echo "Stopping previous MegaProxy JDWP session: $jdwp_session_pid" + kill "$jdwp_session_pid" >/dev/null 2>&1 || true + for _ in {1..20}; do + kill -0 "$jdwp_session_pid" >/dev/null 2>&1 || break + sleep 0.1 + done + fi + rm -f "$jdwp_pid_file" +fi + +if adb -e get-state >/dev/null 2>&1; then + adb -e forward --remove "tcp:$debug_port" >/dev/null 2>&1 || true + adb -e shell am clear-debug-app >/dev/null 2>&1 || true + adb -e shell am force-stop "$package_name" >/dev/null 2>&1 || true +fi diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..5fe7b11 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "MegaProxy" +include(":app")