diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 0000000..5928ff8 --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,38 @@ +name: Build Debug APK + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: gradle + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Build debug APK + run: ./gradlew assembleDebug --no-daemon + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/*.apk + retention-days: 14 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..3aa68d4 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,41 @@ +name: Build Debug APK + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: gradle + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Clean build + run: ./gradlew clean + + - name: Build debug APK + run: ./gradlew assembleDebug --no-daemon --no-build-cache + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/*.apk + retention-days: 14 diff --git a/README.md b/README.md index 2202ca9..8b13789 100644 --- a/README.md +++ b/README.md @@ -1,113 +1 @@ -# Android SSH Proxy -Android application (Android 10+) for system-wide HTTP traffic proxying through an SSH local port-forwarding tunnel using `VpnService.setHttpProxy`. - -## ✨ Features - -* **🚀 System-wide HTTP/HTTPS Proxy:** Tunnels all HTTP/HTTPS traffic through an SSH server. -* **🔑 Secure Key Management:** Generates and stores SSH keys securely using the Android Keystore. -* **🛡️ Host Key Verification:** Protects against MITM attacks by verifying server host keys. -* **🔄 Automatic Reconnection:** Automatically reconnects to the server with an exponential backoff strategy. -* **🧪 Connection Quality Monitoring:** Monitors connection latency and quality in real-time. -* **🌐 "Test All Servers" Feature:** Tests the latency of all configured servers. -* **✅ "Test Connection" without VPN:** Allows testing the internet connection without an active VPN. -* **📱 Multi-language Support:** Available in English and Russian. -* **🎨 Themes:** Supports system, light, and dark themes. - -## 📱 How to Use - -1. **📲 Install the application** on an Android 10+ device. -2. **🔑 On the first launch:** - * The application will automatically generate a new SSH key pair. - * Copy the public key from the setup wizard or the "Keys" section. -3. **📋 Provide the public key** to your server administrator to add it to `~/.ssh/authorized_keys`. -4. **🌐 Add your server:** - * Go to the "Servers" section and click the "+" button. - * Enter your server's details (name, host, port, and username). -5. **🔌 Connect:** - * Select your server from the list and tap the "Connect" button. - * Grant VPN permission to the application. - * Wait for the status to change to "Connected". -6. **✅ Done!** All your HTTP/HTTPS traffic is now routed through your SSH server. - -## 🔧 Server Setup (for Administrators) - -To set up your own proxy server, you can use the provided setup script in the "Instructions" section of the app. The script will automatically: - -* Create a restricted user for SSH tunneling. -* Install and configure Tinyproxy as the HTTP proxy. -* Set up the SSH key for authentication. -* Restrict the user's access to only local port-forwarding. - -**Server Structure:** - -```bash -# Restricted user -/home/username/.ssh/authorized_keys # Your public key - -# HTTP proxy (Tinyproxy) listening on 127.0.0.1:8118 -# SSH forwarding: phone:8080 -> server:8118 -``` - -**Security Considerations:** - -* The user has minimal privileges (port-forwarding only). -* The proxy is only accessible locally (127.0.0.1). -* SSH keys are encrypted using the Android Keystore (AES-256-GCM). -* Host keys are validated to prevent MITM attacks. -* IPv6 is supported to prevent VPN bypass. - -## 🔐 Security Features - -### Private Key Encryption - -* **Android Keystore:** All private keys are encrypted and stored in the Android Keystore. -* **AES-256-GCM:** Modern symmetric encryption is used. -* **TEE/Secure Element:** Encryption keys are protected at the hardware level. -* **Zero-knowledge:** Private keys cannot be extracted, even with root access. - -### Host Key Validation (Known Hosts) - -* **Automatic Saving:** Host keys are automatically saved on the first connection. -* **SHA-256 Fingerprints:** Servers are identified by their SHA-256 fingerprints. -* **MITM Protection:** The connection is blocked if the host key changes. -* **User Confirmation:** A dialog is shown to the user to accept host key changes. - -## ⚠️ Limitations - -* **HTTP Proxy Only:** The application does not modify HTTPS/TLS traffic directly and relies on system support for proxying. -* **Android 10+ Required:** The `setHttpProxy` API is only available on Android 10 and above. -* **No Per-app Exclusions:** The VPN is configured without routes, and the HTTP proxy is applied globally. -* **Server Script:** The server setup script requires root/sudo privileges and should be reviewed before execution. - -## 🛠️ Building from Source - -```bash -./gradlew assembleDebug -# or -./gradlew assembleRelease -``` - -## 🐛 Debugging - -```bash -# Main components -adb logcat -s SshProxyService AppLog ConnectionHealthMonitor - -# Security -adb logcat -s SecureHostKeyVerifier KnownHostsManager KeystoreManager - -# Ping and IP monitoring -adb logcat -s PingMonitor IpLocationService - -# All application components -adb logcat | grep -E "(SshProxy|SSH|VPN|Ping|android-ssh-proxy)" -``` - -## 📄 Disclaimer - -This software is provided "as is", without warranty of any kind. Use at your own risk. The use of this application may violate the policies of your network or service provider. This application implements cryptographic functions; ensure compliance with local laws. - -## 📜 License - -Apache-2.0 diff --git a/app/build.gradle b/app/build.gradle index 801f663..252902f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,9 +11,9 @@ android { defaultConfig { applicationId "com.example.sshproxy" minSdk 24 - targetSdk 34 - versionCode 5 - versionName "1.4" + targetSdk 34 + versionCode 6 + versionName "1.5" } signingConfigs { @@ -51,33 +51,44 @@ android { } dependencies { + // Core Android implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.fragment:fragment-ktx:1.6.2' + + // Material Design & UI + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.viewpager2:viewpager2:1.1.0' + + // Lifecycle & Navigation implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' implementation 'androidx.navigation:navigation-ui-ktx:2.7.7' - implementation "androidx.fragment:fragment-ktx:1.6.2" // Add this line for by viewModels() - // SSH libraries - implementation 'com.jcraft:jsch:0.1.55' + // Local Broadcast Manager + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' + + // Coroutines + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + // SSH Library (modern JSch) + implementation 'com.github.mwiede:jsch:0.2.16' + + // Alternative SSH library (optional – can be removed) implementation 'com.hierynomus:sshj:0.38.0' - - // Bouncycastle for cryptography + + // Cryptography implementation 'org.bouncycastle:bcprov-jdk18on:1.75' implementation 'org.bouncycastle:bcpkix-jdk18on:1.75' - - // Gson for JSON + + // JSON implementation 'com.google.code.gson:gson:2.10.1' - - // Coroutines - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - - // Room database + + // Room Database def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" kapt "androidx.room:room-compiler:$room_version" -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4dda0fc..af04af3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,69 +4,41 @@ - - - - - - - + android:theme="@style/Theme.MaterialComponents.Light.NoActionBar" + android:usesCleartextTraffic="true" + tools:targetApi="31"> + + android:launchMode="singleTop"> - - - - + + + + android:exported="false" + android:foregroundServiceType="dataSync"> - - - - - - - - - + diff --git a/app/src/main/java/com/example/sshproxy/AppLog.kt b/app/src/main/java/com/example/sshproxy/AppLog.kt deleted file mode 100644 index 15a989d..0000000 --- a/app/src/main/java/com/example/sshproxy/AppLog.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.sshproxy - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import java.text.SimpleDateFormat -import java.util.* - -object AppLog { - private val _logMessages = MutableLiveData>(emptyList()) - val logMessages: LiveData> = _logMessages - - private const val MAX_LOG_LINES = 100 - - fun log(message: String) { - val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) - val logEntry = "$timestamp: $message" - - val currentLogs = _logMessages.value?.toMutableList() ?: mutableListOf() - currentLogs.add(0, logEntry) // Add new message to the top - - if (currentLogs.size > MAX_LOG_LINES) { - currentLogs.removeAt(currentLogs.size - 1) // Remove the oldest - } - - _logMessages.postValue(currentLogs) - } - - fun clear() { - _logMessages.postValue(emptyList()) - } -} diff --git a/app/src/main/java/com/example/sshproxy/ConfigFragment.kt b/app/src/main/java/com/example/sshproxy/ConfigFragment.kt new file mode 100644 index 0000000..e4a1cee --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/ConfigFragment.kt @@ -0,0 +1,465 @@ +package com.example.sshproxy + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.VpnService +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import java.net.Inet4Address +import java.net.NetworkInterface + +class ConfigFragment : Fragment() { + + private lateinit var sshDetailsInput: EditText + private lateinit var proxyInput: EditText + private lateinit var payloadInput: EditText + private lateinit var splitDelayInput: EditText + private lateinit var dnsPrimaryInput: EditText + private lateinit var dnsSecondaryInput: EditText + private lateinit var enableCompressionCheck: CheckBox + private lateinit var alwaysReconnectCheck: CheckBox + private lateinit var followRedirectsCheck: CheckBox + private lateinit var usePayloadCheck: CheckBox + private lateinit var proxySslCheck: CheckBox + private lateinit var mtuInput: EditText + private lateinit var sendBufferInput: EditText + private lateinit var receiveBufferInput: EditText + private lateinit var pingUrlInput: EditText + private lateinit var pingIntervalInput: EditText + private lateinit var pingTimeoutInput: EditText + private lateinit var toggleButton: Button + private lateinit var statusText: TextView + private lateinit var localIpText: TextView + + private var currentSshHost: String = "" + private var currentSshPort: String = "" + private var currentSshUser: String = "" + private var currentSshPass: String = "" + private var currentProxyHost: String = "" + private var currentProxyPort: String = "" + private var currentPayload: String = "" + private var currentSplitDelay: Int = 500 + private var currentDnsPrimary: String = "1.1.1.1" + private var currentDnsSecondary: String = "1.0.0.1" + private var currentEnableCompression: Boolean = true + private var currentAlwaysReconnect: Boolean = false + private var currentFollowRedirects: Boolean = true + private var currentUsePayload: Boolean = true + private var currentProxySsl: Boolean = false + private var currentMtu: Int = 1500 + private var currentSendBuffer: Int = 16384 + private var currentReceiveBuffer: Int = 32768 + private var currentPingUrl: String = "https://dns.google" + private var currentPingInterval: Int = 2000 + private var currentPingTimeout: Int = 5000 + + private val VPN_REQUEST_CODE = 100 + private lateinit var configManager: ConfigManager + + private val statusReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val status = intent?.getStringExtra("status") ?: return + activity?.runOnUiThread { + when (status) { + "Connected" -> { + toggleButton.text = "Disconnect" + toggleButton.setBackgroundColor(ContextCompat.getColor(requireContext(), android.R.color.holo_red_dark)) + updateStatus("Connected", android.R.color.holo_green_dark) + } + "Disconnected" -> { + toggleButton.text = "Connect" + toggleButton.setBackgroundColor(ContextCompat.getColor(requireContext(), android.R.color.holo_green_dark)) + updateStatus("Disconnected", android.R.color.holo_red_dark) + } + else -> updateStatus(status, android.R.color.holo_orange_dark) + } + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_config, container, false) + + sshDetailsInput = view.findViewById(R.id.sshDetailsInput) + proxyInput = view.findViewById(R.id.proxyInput) + payloadInput = view.findViewById(R.id.payloadInput) + splitDelayInput = view.findViewById(R.id.splitDelayInput) + dnsPrimaryInput = view.findViewById(R.id.dnsPrimaryInput) + dnsSecondaryInput = view.findViewById(R.id.dnsSecondaryInput) + enableCompressionCheck = view.findViewById(R.id.enableCompressionCheck) + alwaysReconnectCheck = view.findViewById(R.id.alwaysReconnectCheck) + followRedirectsCheck = view.findViewById(R.id.followRedirectsCheck) + usePayloadCheck = view.findViewById(R.id.usePayloadCheck) + proxySslCheck = view.findViewById(R.id.proxySslCheck) + mtuInput = view.findViewById(R.id.mtuInput) + sendBufferInput = view.findViewById(R.id.sendBufferInput) + receiveBufferInput = view.findViewById(R.id.receiveBufferInput) + pingUrlInput = view.findViewById(R.id.pingUrlInput) + pingIntervalInput = view.findViewById(R.id.pingIntervalInput) + pingTimeoutInput = view.findViewById(R.id.pingTimeoutInput) + toggleButton = view.findViewById(R.id.toggleButton) + statusText = view.findViewById(R.id.statusText) + localIpText = view.findViewById(R.id.localIpText) + + // Show local IP + updateLocalIp() + + proxyInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + if (s != null) { + val cleaned = s.toString().replace(Regex("[\\s\\p{Cntrl}]"), "") + if (cleaned != s.toString()) { + proxyInput.setText(cleaned) + proxyInput.setSelection(cleaned.length) + } + } + } + }) + + configManager = ConfigManager(requireContext()) + loadSavedConfig() + + toggleButton.setOnClickListener { + if (toggleButton.text == "Connect") connectAction() + else disconnectAction() + } + + LocalBroadcastManager.getInstance(requireContext()).registerReceiver(statusReceiver, IntentFilter("VPN_STATUS")) + + return view + } + + override fun onResume() { + super.onResume() + // Refresh local IP when the fragment is resumed + updateLocalIp() + } + + private fun updateLocalIp() { + val ip = getLocalIpAddress() + localIpText.text = if (ip != null) "Local IP: $ip" else "Local IP: not available" + } + + private fun getLocalIpAddress(): String? { + try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val intf = interfaces.nextElement() + val addresses = intf.inetAddresses + while (addresses.hasMoreElements()) { + val addr = addresses.nextElement() + if (!addr.isLoopbackAddress && addr is Inet4Address) { + return addr.hostAddress + } + } + } + } catch (e: Exception) { + LogManager.addLog("[ERROR] Failed to get local IP: ${e.message}") + } + return null + } + + private fun connectAction() { + val sshDetails = sshDetailsInput.text.toString().trim() + var proxyString = proxyInput.text.toString().trim() + val payload = payloadInput.text.toString().trim() + val splitDelay = splitDelayInput.text.toString().toIntOrNull() ?: 500 + val dnsPrimary = dnsPrimaryInput.text.toString().trim().takeIf { it.isNotEmpty() } ?: "1.1.1.1" + val dnsSecondary = dnsSecondaryInput.text.toString().trim().takeIf { it.isNotEmpty() } ?: "1.0.0.1" + val enableCompression = enableCompressionCheck.isChecked + val alwaysReconnect = alwaysReconnectCheck.isChecked + val followRedirects = followRedirectsCheck.isChecked + val usePayload = usePayloadCheck.isChecked + val proxySsl = proxySslCheck.isChecked + val mtu = mtuInput.text.toString().toIntOrNull() ?: 1500 + val sendBuffer = sendBufferInput.text.toString().toIntOrNull() ?: 16384 + val receiveBuffer = receiveBufferInput.text.toString().toIntOrNull() ?: 32768 + val pingUrl = pingUrlInput.text.toString().trim().takeIf { it.isNotEmpty() } ?: "https://dns.google" + val pingInterval = pingIntervalInput.text.toString().toIntOrNull() ?: 2000 + val pingTimeout = pingTimeoutInput.text.toString().toIntOrNull() ?: 5000 + + LogManager.addLog("[DEBUG] Raw proxy input: '$proxyString'") + LogManager.addLog("[DEBUG] Raw payload length: ${payload.length}") + LogManager.addLog("[DEBUG] Payload first 100 chars: ${payload.take(100)}...") + + if (proxyString.isNotEmpty() && !proxyString.matches(Regex("^[a-zA-Z0-9.:-]+$"))) { + LogManager.addLog("[WARN] Proxy contains invalid characters – resetting to empty") + proxyString = "" + proxyInput.setText("") + } + + val parseResult = parseSshDetails(sshDetails) + if (parseResult == null) { + LogManager.addLog("[ERROR] Invalid SSH details format. Use host:port@username:password") + updateStatus("Invalid SSH format", android.R.color.holo_red_dark) + return + } + + val (host, port, user, pass) = parseResult + + val proxyPair = parseProxyString(proxyString) + val (proxyHost, proxyPort) = proxyPair ?: Pair("", 0) + LogManager.addLog("[DEBUG] Decoded proxy: '$proxyHost:$proxyPort'") + + val config = ConfigManager.TunnelConfig( + sshDetails = sshDetails, + proxyInput = proxyString, + payload = payload, + splitDelay = splitDelay, + dnsPrimary = dnsPrimary, + dnsSecondary = dnsSecondary, + pingTarget = "1.1.1.1", + enableCompression = enableCompression, + alwaysReconnect = alwaysReconnect, + followRedirects = followRedirects, + mtu = mtu, + sendBuffer = sendBuffer, + receiveBuffer = receiveBuffer, + pingUrl = pingUrl, + pingInterval = pingInterval, + pingTimeout = pingTimeout, + usePayload = usePayload, + proxySsl = proxySsl + ) + + if (!configManager.validateConfig(config)) { + LogManager.addLog("[ERROR] Invalid config – please check fields") + updateStatus("Invalid config", android.R.color.holo_red_dark) + return + } + + configManager.saveConfig(config) + + LogManager.clearLogs() + LogManager.addLog("[Config] SSH: $host:$port@$user") + LogManager.addLog("[Config] Proxy: $proxyHost:$proxyPort (decoded)") + LogManager.addLog("[Config] DNS: $dnsPrimary / $dnsSecondary") + LogManager.addLog("[Config] Use Payload: $usePayload") + LogManager.addLog("[Config] Payload length: ${payload.length}") + LogManager.addLog("[INFO] Connect button pressed") + + updateStatus("Requesting VPN permission...", android.R.color.holo_orange_dark) + + currentSshHost = host + currentSshPort = port + currentSshUser = user + currentSshPass = pass + currentProxyHost = proxyHost + currentProxyPort = proxyPort.toString() + currentPayload = payload + currentSplitDelay = splitDelay + currentDnsPrimary = dnsPrimary + currentDnsSecondary = dnsSecondary + currentEnableCompression = enableCompression + currentAlwaysReconnect = alwaysReconnect + currentFollowRedirects = followRedirects + currentUsePayload = usePayload + currentProxySsl = proxySsl + currentMtu = mtu + currentSendBuffer = sendBuffer + currentReceiveBuffer = receiveBuffer + currentPingUrl = pingUrl + currentPingInterval = pingInterval + currentPingTimeout = pingTimeout + + requestVpnPermission() + } + + private fun disconnectAction() { + LogManager.addLog("[INFO] Disconnect button pressed") + toggleButton.text = "Connect" + toggleButton.setBackgroundColor(ContextCompat.getColor(requireContext(), android.R.color.holo_green_dark)) + updateStatus("Disconnecting...", android.R.color.holo_orange_dark) + stopVpnService() + + Handler(Looper.getMainLooper()).postDelayed({ + if (toggleButton.text == "Disconnect") { + toggleButton.text = "Connect" + toggleButton.setBackgroundColor(ContextCompat.getColor(requireContext(), android.R.color.holo_green_dark)) + updateStatus("Disconnected", android.R.color.holo_red_dark) + } + }, 2000) + } + + private fun loadSavedConfig() { + val config = configManager.loadConfig() + if (config != null) { + sshDetailsInput.setText(config.sshDetails) + proxyInput.setText(config.proxyInput) + payloadInput.setText(config.payload) + splitDelayInput.setText(config.splitDelay.toString()) + dnsPrimaryInput.setText(config.dnsPrimary) + dnsSecondaryInput.setText(config.dnsSecondary) + enableCompressionCheck.isChecked = config.enableCompression + alwaysReconnectCheck.isChecked = config.alwaysReconnect + followRedirectsCheck.isChecked = config.followRedirects + usePayloadCheck.isChecked = config.usePayload + proxySslCheck.isChecked = config.proxySsl + mtuInput.setText(config.mtu.toString()) + sendBufferInput.setText(config.sendBuffer.toString()) + receiveBufferInput.setText(config.receiveBuffer.toString()) + pingUrlInput.setText(config.pingUrl) + pingIntervalInput.setText(config.pingInterval.toString()) + pingTimeoutInput.setText(config.pingTimeout.toString()) + LogManager.addLog("[Config] Loaded from ConfigManager") + } else { + setDefaultConfig() + } + } + + private fun setDefaultConfig() { + sshDetailsInput.setText("premium.rickydewizard.site:80@Rickydewizard:apps") + proxyInput.setText("viton.com:80") + payloadInput.setText("GET / HTTP/1.1[crlf]Host: [host][crlf]Upgrade: websocket[crlf][crlf]") + splitDelayInput.setText("500") + dnsPrimaryInput.setText("1.1.1.1") + dnsSecondaryInput.setText("1.0.0.1") + enableCompressionCheck.isChecked = true + alwaysReconnectCheck.isChecked = false + followRedirectsCheck.isChecked = true + usePayloadCheck.isChecked = true + proxySslCheck.isChecked = false + mtuInput.setText("1500") + sendBufferInput.setText("16384") + receiveBufferInput.setText("32768") + pingUrlInput.setText("https://dns.google") + pingIntervalInput.setText("2000") + pingTimeoutInput.setText("5000") + LogManager.addLog("[Config] Using defaults") + } + + private fun parseSshDetails(input: String): Quadruple? { + val atIndex = input.indexOf('@') + if (atIndex == -1) return null + val left = input.substring(0, atIndex) + val right = input.substring(atIndex + 1) + val colonLeft = left.indexOf(':') + val host = if (colonLeft == -1) left else left.substring(0, colonLeft) + val port = if (colonLeft == -1) "22" else left.substring(colonLeft + 1) + val colonRight = right.indexOf(':') + if (colonRight == -1) return null + val user = right.substring(0, colonRight) + val pass = right.substring(colonRight + 1) + return Quadruple(host, port, user, pass) + } + + private fun parseProxyString(input: String): Pair? { + val clean = input.replace(Regex("[\\s\\p{Cntrl}]"), "") + if (clean.isEmpty()) return null + if (!clean.matches(Regex("^[a-zA-Z0-9.:-]+$"))) { + LogManager.addLog("[ProxyParser] Invalid characters in proxy '$clean' – treating as empty") + return null + } + try { + val decoded = android.util.Base64.decode(clean, android.util.Base64.DEFAULT) + val decodedStr = String(decoded).trim() + if (decodedStr.isNotEmpty() && decodedStr.matches(Regex("^[a-zA-Z0-9.:-]+$"))) { + val parts = decodedStr.split(":") + val host = parts[0] + val port = parts.getOrElse(1) { "80" }.toIntOrNull() ?: 80 + if (host.isNotEmpty() && port in 1..65535) { + LogManager.addLog("[ProxyParser] Decoded Base64 proxy: '$host:$port'") + return Pair(host, port) + } + } + } catch (_: Exception) { /* not Base64 */ } + val parts = clean.split(":") + return when (parts.size) { + 1 -> if (parts[0].isNotEmpty()) Pair(parts[0], 80) else null + 2 -> { + val host = parts[0] + val port = parts[1].toIntOrNull() + if (host.isNotEmpty() && port != null && port in 1..65535) Pair(host, port) else null + } + else -> null + } + } + + private data class Quadruple(val first: A, val second: B, val third: C, val fourth: D) + + private fun requestVpnPermission() { + val intent = VpnService.prepare(requireContext()) + if (intent != null) startActivityForResult(intent, VPN_REQUEST_CODE) + else startVpnService() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == VPN_REQUEST_CODE) { + if (resultCode == android.app.Activity.RESULT_OK) { + startVpnService() + } else { + LogManager.addLog("[ERROR] VPN permission denied") + updateStatus("VPN permission denied", android.R.color.holo_red_dark) + toggleButton.text = "Connect" + toggleButton.setBackgroundColor(ContextCompat.getColor(requireContext(), android.R.color.holo_green_dark)) + } + } + } + + private fun startVpnService() { + LogManager.addLog("[DEBUG] Sending proxyHost='$currentProxyHost', proxyPort='$currentProxyPort'") + LogManager.addLog("[DEBUG] Sending payload length: ${currentPayload.length}") + LogManager.addLog("[DEBUG] Sending payload (first 100 chars): ${currentPayload.take(100)}...") + + val serviceIntent = Intent(requireContext(), CustomVpnService::class.java) + serviceIntent.action = CustomVpnService.ACTION_CONNECT + serviceIntent.putExtra("sshHost", currentSshHost) + serviceIntent.putExtra("sshPort", currentSshPort) + serviceIntent.putExtra("sshUser", currentSshUser) + serviceIntent.putExtra("sshPass", currentSshPass) + serviceIntent.putExtra("proxyHost", currentProxyHost) + serviceIntent.putExtra("proxyPort", currentProxyPort) + serviceIntent.putExtra("payload", currentPayload) + serviceIntent.putExtra("splitDelay", currentSplitDelay) + serviceIntent.putExtra("dnsPrimary", currentDnsPrimary) + serviceIntent.putExtra("dnsSecondary", currentDnsSecondary) + serviceIntent.putExtra("enableCompression", currentEnableCompression) + serviceIntent.putExtra("alwaysReconnect", currentAlwaysReconnect) + serviceIntent.putExtra("followRedirects", currentFollowRedirects) + serviceIntent.putExtra("usePayload", currentUsePayload) + serviceIntent.putExtra("proxySsl", currentProxySsl) + serviceIntent.putExtra("mtu", currentMtu) + serviceIntent.putExtra("sendBuffer", currentSendBuffer) + serviceIntent.putExtra("receiveBuffer", currentReceiveBuffer) + serviceIntent.putExtra("pingUrl", currentPingUrl) + serviceIntent.putExtra("pingInterval", currentPingInterval) + serviceIntent.putExtra("pingTimeout", currentPingTimeout) + requireContext().startService(serviceIntent) + } + + private fun stopVpnService() { + val serviceIntent = Intent(requireContext(), CustomVpnService::class.java) + serviceIntent.action = CustomVpnService.ACTION_DISCONNECT + requireContext().startService(serviceIntent) + } + + fun updateStatus(status: String, colorId: Int) { + activity?.runOnUiThread { + statusText.text = "Status: $status" + statusText.setTextColor(ContextCompat.getColor(requireContext(), colorId)) + } + } + + override fun onDestroyView() { + super.onDestroyView() + LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(statusReceiver) + } +} diff --git a/app/src/main/java/com/example/sshproxy/ConfigManager.kt b/app/src/main/java/com/example/sshproxy/ConfigManager.kt new file mode 100644 index 0000000..f647099 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/ConfigManager.kt @@ -0,0 +1,75 @@ +package com.example.sshproxy + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +class ConfigManager(private val context: Context) { + + private val prefs: SharedPreferences = + context.getSharedPreferences("gtunnel_configs", Context.MODE_PRIVATE) + private val gson = Gson() + + data class TunnelConfig( + val sshDetails: String, + val proxyInput: String, + val payload: String, + val splitDelay: Int, + val dnsPrimary: String, + val dnsSecondary: String, + val pingTarget: String, + val enableCompression: Boolean, + val alwaysReconnect: Boolean, + val followRedirects: Boolean, + val mtu: Int, + val sendBuffer: Int, + val receiveBuffer: Int, + val pingUrl: String, + val pingInterval: Int, + val pingTimeout: Int, + val usePayload: Boolean = true, + val proxySsl: Boolean = false + ) + + fun saveConfig(config: TunnelConfig) { + val json = gson.toJson(config) + prefs.edit().putString("current_config", json).apply() + } + + fun loadConfig(): TunnelConfig? { + val json = prefs.getString("current_config", null) + return if (json != null) { + try { + gson.fromJson(json, TunnelConfig::class.java) + } catch (e: Exception) { + null + } + } else null + } + + fun validateConfig(config: TunnelConfig): Boolean { + if (config.sshDetails.isEmpty()) return false + if (!config.sshDetails.contains("@") || !config.sshDetails.contains(":")) return false + if (config.mtu !in 1280..9000) return false + if (config.splitDelay < 0) return false + return true + } + + fun saveConfigList(configs: List) { + val json = gson.toJson(configs) + prefs.edit().putString("config_list", json).apply() + } + + fun loadConfigList(): List { + val json = prefs.getString("config_list", null) + return if (json != null) { + try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) + } catch (e: Exception) { + emptyList() + } + } else emptyList() + } +} diff --git a/app/src/main/java/com/example/sshproxy/ConfigRepository.kt b/app/src/main/java/com/example/sshproxy/ConfigRepository.kt new file mode 100644 index 0000000..af0d2bf --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/ConfigRepository.kt @@ -0,0 +1,61 @@ +package com.example.sshproxy + +import com.example.sshproxy.data.ConfigDatabase +import com.example.sshproxy.data.ConfigEntity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ConfigRepository(private val context: android.content.Context) { + + private val database = ConfigDatabase.getDatabase(context) + private val dao = database.configDao() + + fun saveConfig( + sshDetails: String, + proxyInput: String, + payload: String, + splitDelay: Int, + dnsServer: String, + pingTarget: String, + enableCompression: Boolean, + mtu: Int, + sendBuffer: Int, + receiveBuffer: Int, + pingUrl: String, + pingInterval: Int, + pingTimeout: Int, + alwaysReconnect: Boolean, + followRedirects: Boolean // NEW + ) { + CoroutineScope(Dispatchers.IO).launch { + val entity = ConfigEntity( + sshDetails = sshDetails, + proxyInput = proxyInput, + payload = payload, + splitDelay = splitDelay, + dnsServer = dnsServer, + pingTarget = pingTarget, + enableCompression = enableCompression, + mtu = mtu, + sendBuffer = sendBuffer, + receiveBuffer = receiveBuffer, + pingUrl = pingUrl, + pingInterval = pingInterval, + pingTimeout = pingTimeout, + alwaysReconnect = alwaysReconnect, + followRedirects = followRedirects + ) + dao.insert(entity) + } + } + + fun loadLatestConfig(callback: (ConfigEntity?) -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + val config = dao.getLatest() + kotlinx.coroutines.GlobalScope.launch(Dispatchers.Main) { + callback(config) + } + } + } +} diff --git a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt new file mode 100644 index 0000000..b378df4 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt @@ -0,0 +1,526 @@ +package com.example.sshproxy + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.os.ParcelFileDescriptor +import android.os.PowerManager +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.example.sshproxy.payload.PayloadProcessor +import com.example.sshproxy.proxy.ConnectionStrategy +import com.example.sshproxy.proxy.ProxyConnectionException +import hev.sockstun.TProxyService +import com.jcraft.jsch.JSch +import com.jcraft.jsch.JSchException +import com.jcraft.jsch.Session +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.io.FileOutputStream +import java.net.Socket +import java.net.SocketTimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +class CustomVpnService : VpnService() { + + companion object { + private const val CHANNEL_ID = "vpn_channel" + private const val NOTIFICATION_ID = 1 + private const val TAG = "CustomVpnService" + private const val WAKELOCK_TAG = "HttpCustom:WakeLock" + private const val VPN_IP = "10.0.0.2" + + const val ACTION_CONNECT = "com.example.sshproxy.CONNECT" + const val ACTION_DISCONNECT = "com.example.sshproxy.DISCONNECT" + const val ACTION_RECONNECT = "com.example.sshproxy.RECONNECT" + } + + enum class VpnState { + IDLE, CONNECTING, CONNECTED, DISCONNECTING, ERROR, RECONNECTING + } + + private val _state = MutableStateFlow(VpnState.IDLE) + val state: StateFlow = _state.asStateFlow() + + // Core members + private var sshSession: Session? = null + private var socksProxy: LocalSocks5Proxy? = null + private var socksPort: Int = 0 + private var tunnelSocket: Socket? = null + private var vpnInterface: ParcelFileDescriptor? = null + private val isConnected = AtomicBoolean(false) + private var pingJob: Job? = null + private var reconnectJob: Job? = null + private var stateJob: Job? = null + private var wakeLock: PowerManager.WakeLock? = null + + private var reconnectAttempts = 0 + private val MAX_RECONNECT_ATTEMPTS = 10 + private val BASE_RECONNECT_DELAY_MS = 2000L + + // --- Config --- + private var sshHost: String = "" + private var sshPort: String = "" + private var sshUser: String = "" + private var sshPass: String = "" + private var proxyHost: String = "" + private var proxyPort: String = "" + private var payload: String = "" + private var splitDelayMs: Int = 500 + private var dnsPrimary: String = "1.1.1.1" + private var dnsSecondary: String = "1.0.0.1" + private var pingTarget: String = "1.1.1.1" + private var enableCompression: Boolean = true + private var alwaysReconnect: Boolean = false + private var followRedirects: Boolean = true + private var usePayload: Boolean = true + private var useSsl: Boolean = false + private var mtu: Int = 1500 + private var sendBuffer: Int = 16384 + private var receiveBuffer: Int = 32768 + private var pingUrl: String = "https://dns.google" + private var pingInterval: Int = 2000 + private var pingTimeout: Int = 10000 + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "SERVICE CREATED") + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG) + createNotificationChannel() + startStateMonitoring() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_CONNECT -> { + extractConfig(intent) + connect() + } + ACTION_DISCONNECT -> disconnect() + ACTION_RECONNECT -> reconnect() + else -> { + if (intent != null && intent.hasExtra("sshHost")) { + extractConfig(intent) + connect() + } + } + } + return START_STICKY + } + + private fun extractConfig(intent: Intent) { + sshHost = intent.getStringExtra("sshHost") ?: "" + sshPort = intent.getStringExtra("sshPort") ?: "" + sshUser = intent.getStringExtra("sshUser") ?: "" + sshPass = intent.getStringExtra("sshPass") ?: "" + proxyHost = intent.getStringExtra("proxyHost") ?: "" + proxyPort = intent.getStringExtra("proxyPort") ?: "" + payload = intent.getStringExtra("payload") ?: "" + splitDelayMs = intent.getIntExtra("splitDelay", 500) + dnsPrimary = intent.getStringExtra("dnsPrimary") ?: "1.1.1.1" + dnsSecondary = intent.getStringExtra("dnsSecondary") ?: "1.0.0.1" + pingTarget = intent.getStringExtra("pingTarget") ?: "1.1.1.1" + enableCompression = intent.getBooleanExtra("enableCompression", true) + alwaysReconnect = intent.getBooleanExtra("alwaysReconnect", false) + followRedirects = intent.getBooleanExtra("followRedirects", true) + usePayload = intent.getBooleanExtra("usePayload", true) + useSsl = intent.getBooleanExtra("proxySsl", false) + mtu = intent.getIntExtra("mtu", 1500) + sendBuffer = intent.getIntExtra("sendBuffer", 16384) + receiveBuffer = intent.getIntExtra("receiveBuffer", 32768) + pingUrl = intent.getStringExtra("pingUrl") ?: "https://dns.google" + pingInterval = intent.getIntExtra("pingInterval", 2000) + pingTimeout = intent.getIntExtra("pingTimeout", 10000) + LogManager.addLog("[DEBUG] Payload received: ${payload.take(100)}...") + } + + private fun connect() { + if (_state.value == VpnState.CONNECTING || _state.value == VpnState.CONNECTED) { + LogManager.addLog("[WARN] Already connecting or connected") + return + } + if (sshHost.isEmpty() || sshPort.isEmpty() || sshUser.isEmpty() || sshPass.isEmpty()) { + LogManager.addLog("[ERROR] Missing SSH details") + _state.value = VpnState.ERROR + return + } + _state.value = VpnState.CONNECTING + acquireWakeLock() + showNotification("Connecting...") + LogManager.addLog("starting service") + LogManager.addLog("ssh starting") + + CoroutineScope(Dispatchers.IO).launch { + var attempts = 0 + val maxAttempts = 10 + var compressionFailed = false + + while (attempts < maxAttempts && !isConnected.get()) { + try { + doConnect(compressionFailed) + return@launch + } catch (e: ProxyConnectionException) { + LogManager.addLog("[ERROR] Connection failed: ${e.message}") + PayloadProcessor.rotateIndex++ + attempts++ + LogManager.addLog("Rotating to next host (attempt $attempts/$maxAttempts)") + delay(2000) + } catch (e: SocketTimeoutException) { + LogManager.addLog("[ERROR] Socket timeout") + PayloadProcessor.rotateIndex++ + attempts++ + delay(2000) + } catch (e: JSchException) { + if (e.message?.contains("Algorithm negotiation") == true && !compressionFailed) { + LogManager.addLog("[ERROR] Compression not supported. Disabling and retrying...") + compressionFailed = true + enableCompression = false + continue + } else { + LogManager.addLog("[ERROR] SSH failed: ${e.message}") + PayloadProcessor.rotateIndex++ + attempts++ + delay(2000) + } + } catch (e: Exception) { + LogManager.addLog("[ERROR] Connection failed: ${e.message}") + PayloadProcessor.rotateIndex++ + attempts++ + delay(2000) + } + } + + if (!isConnected.get()) { + LogManager.addLog("[ERROR] All hosts failed. Stopping service.") + _state.value = VpnState.ERROR + sendStatus("Disconnected") + showNotification("Connection failed") + releaseWakeLock() + if (alwaysReconnect) reconnect() + } + } + } + + private suspend fun doConnect(compressionFailed: Boolean) { + val strategy = ConnectionStrategy() + val socket = try { + strategy.establishTunnel( + proxyHost = proxyHost.ifEmpty { sshHost }, + proxyPort = if (proxyPort.isNotEmpty()) proxyPort.toInt() else sshPort.toInt(), + sshHost = sshHost, + sshPort = sshPort.toInt(), + payload = payload, + userAgent = "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36", + auth = null, + connectTimeout = 25000, + readTimeout = 5000, + followRedirects = followRedirects, + splitDelayMs = splitDelayMs.toLong(), + useSsl = useSsl, + usePayload = usePayload + ) + } catch (e: ProxyConnectionException) { + LogManager.addLog("[ERROR] All connection strategies failed: ${e.message}") + throw e + } + + tunnelSocket = socket + LogManager.addLog("connected to socket ${socket.remoteSocketAddress}") + + establishSSH(compressionFailed) + + isConnected.set(true) + _state.value = VpnState.CONNECTED + reconnectAttempts = 0 + sendStatus("Connected") + showNotification("Connected ✓") + try { + setupVpn() + } catch (e: Exception) { + LogManager.addLog("[ERROR] setupVpn crashed: ${e.message}") + e.printStackTrace() + stopSelf() + return + } + startPing() + + if (alwaysReconnect) startReconnectMonitor() + } + + private fun establishSSH(compressionRetry: Boolean = false) { + val jsch = JSch() + val session = jsch.getSession(sshUser, sshHost, sshPort.toInt()) + session.setPassword(sshPass) + session.setConfig("StrictHostKeyChecking", "no") + session.setConfig("ServerAliveInterval", "30") + session.setConfig("ServerAliveCountMax", "3") + session.setConfig("TCPKeepAlive", "yes") + + session.setConfig("compression.c2s", "none") + session.setConfig("compression.s2c", "none") + LogManager.addLog("SSH compression forced OFF (server compatibility)") + + session.setSocketFactory(object : com.jcraft.jsch.SocketFactory { + override fun createSocket(host: String?, port: Int): Socket = tunnelSocket ?: Socket(host, port) + override fun getInputStream(socket: Socket) = socket.getInputStream() + override fun getOutputStream(socket: Socket) = socket.getOutputStream() + }) + + session.connect(25000) + if (session.isConnected) { + sshSession = session + LogManager.addLog("SSH authenticated") + } else { + throw JSchException("SSH connection failed") + } + } + + private fun setupVpn() { + if (tunnelSocket == null || tunnelSocket!!.isClosed) { + LogManager.addLog("[ERROR] Tunnel socket is closed before VPN setup") + return + } + + // Let hev-socks5-tunnel take full control + vpnInterface = Builder() + .addAddress("10.255.255.1", 32) // dummy – will be overwritten by hev + .setMtu(mtu) + .establish() + + if (vpnInterface == null) { + LogManager.addLog("[ERROR] VPN interface creation failed") + return + } + + Thread.sleep(500) + + // Start SOCKS5 proxy + try { + val proxy = LocalSocks5Proxy(sshSession!!) + socksPort = proxy.start() + socksProxy = proxy + LogManager.addLog("[SOCKS5] Proxy running on 127.0.0.1:$socksPort") + } catch (e: Exception) { + LogManager.addLog("[ERROR] SOCKS5 proxy failed: ${e.message}") + throw e + } + + // Write YAML config – hev manages everything here + val configPath = createTProxyConfig(socksPort, mtu) + if (configPath == null) { + LogManager.addLog("[ERROR] Failed to create tproxy config") + stopSelf() + return + } + LogManager.addLog("[tproxy] Config written to $configPath") + + // Start hev + val tunFd = vpnInterface!!.fd + try { + LogManager.addLog("[hev-socks5-tunnel] Starting with config=$configPath, tunFd=$tunFd") + TProxyService.TProxyStartService(configPath, tunFd) + LogManager.addLog("[hev-socks5-tunnel] Started successfully") + } catch (e: UnsatisfiedLinkError) { + LogManager.addLog("[ERROR] Native library not loaded: ${e.message}") + throw e + } catch (e: Exception) { + LogManager.addLog("[ERROR] hev-socks5-tunnel exception: ${e.message}") + throw e + } + + LogManager.addLog("VPN and SOCKS5 tunnel ready (hev manages routing)") + } + + private fun createTProxyConfig(socksPort: Int, mtu: Int): String? { + return try { + val configFile = File(filesDir, "tproxy.conf") + configFile.createNewFile() + FileOutputStream(configFile).use { fos -> + val config = """ + misc: + task-stack-size: 65536 + log-file: /data/local/tmp/hev.log + log-level: debug + tunnel: + name: tun0 + mtu: $mtu + ipv4: 10.0.0.2/24 + icmp: 'reply' + routes: + - "0.0.0.0/0" + socks5: + port: $socksPort + address: '127.0.0.1' + udp: 'udp' + mapdns: + address: '1.1.1.1' + port: 53 + network: 'udp' + netmask: 32 + cache-size: 256 + """.trimIndent() + fos.write(config.toByteArray()) + } + configFile.absolutePath + } catch (e: Exception) { + LogManager.addLog("[ERROR] Failed to create tproxy config: ${e.message}") + null + } + } + + private fun disconnect() { + _state.value = VpnState.DISCONNECTING + isConnected.set(false) + reconnectJob?.cancel() + pingJob?.cancel() + stateJob?.cancel() + + try { + TProxyService.TProxyStopService() + LogManager.addLog("[hev-socks5-tunnel] Stopped") + } catch (e: Exception) { + LogManager.addLog("[ERROR] Failed to stop hev-socks5-tunnel: ${e.message}") + } + + socksProxy?.stop() + socksProxy = null + + sshSession?.disconnect() + sshSession = null + tunnelSocket?.close() + tunnelSocket = null + vpnInterface?.close() + vpnInterface = null + + stopForeground(true) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_ID) + sendStatus("Disconnected") + LogManager.addLog("VPN stopped") + releaseWakeLock() + _state.value = VpnState.IDLE + } + + private fun reconnect() { + if (_state.value == VpnState.RECONNECTING) return + _state.value = VpnState.RECONNECTING + reconnectJob = CoroutineScope(Dispatchers.IO).launch { + while (reconnectAttempts < MAX_RECONNECT_ATTEMPTS && !isConnected.get()) { + val delay = BASE_RECONNECT_DELAY_MS * (1L shl reconnectAttempts.coerceAtMost(8)) + LogManager.addLog("Reconnect attempt ${reconnectAttempts + 1} in ${delay}ms") + delay(delay) + connect() + if (isConnected.get()) { + reconnectAttempts = 0 + return@launch + } + reconnectAttempts++ + } + if (!isConnected.get()) { + _state.value = VpnState.ERROR + LogManager.addLog("[ERROR] All reconnect attempts failed") + sendStatus("Disconnected") + showNotification("Reconnection failed") + } + } + } + + private fun startPing() { + pingJob = CoroutineScope(Dispatchers.IO).launch { + while (isConnected.get()) { + delay(pingInterval.toLong()) + try { + val url = java.net.URL(pingUrl) + val connection = url.openConnection() as java.net.HttpURLConnection + connection.connectTimeout = pingTimeout + connection.readTimeout = pingTimeout + connection.requestMethod = "GET" + val responseCode = connection.responseCode + if (responseCode == 200 || responseCode == 204) { + LogManager.addLog("Ping success") + } else { + LogManager.addLog("Ping timeout (code $responseCode)") + } + } catch (e: Exception) { + LogManager.addLog("Ping timeout") + } + } + } + } + + private fun startReconnectMonitor() { + stateJob = CoroutineScope(Dispatchers.IO).launch { + while (isConnected.get()) { + delay(1000) + if (tunnelSocket == null || tunnelSocket!!.isClosed) { + LogManager.addLog("[WARN] Connection lost – reconnecting") + isConnected.set(false) + reconnect() + } + } + } + } + + private fun startStateMonitoring() { + stateJob = CoroutineScope(Dispatchers.Main).launch { + state.collect { vpnState -> + sendStatus(vpnState.name) + } + } + } + + private fun sendStatus(status: String) { + val intent = Intent("VPN_STATUS") + intent.putExtra("status", status) + LocalBroadcastManager.getInstance(this).sendBroadcast(intent) + } + + private fun showNotification(message: String) { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Gtunnel") + .setContentText(message) + .setSmallIcon(android.R.drawable.ic_menu_share) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + startForeground(NOTIFICATION_ID, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + // WakeLock helpers (defined only once) + private fun acquireWakeLock() { + try { + wakeLock?.acquire(10 * 60 * 1000L) + } catch (e: Exception) { + LogManager.addLog("[ERROR] WakeLock acquire failed: ${e.message}") + } + } + + private fun releaseWakeLock() { + try { + if (wakeLock?.isHeld == true) wakeLock?.release() + } catch (e: Exception) { + LogManager.addLog("[ERROR] WakeLock release failed: ${e.message}") + } + } + + override fun onDestroy() { + super.onDestroy() + disconnect() + releaseWakeLock() + Log.d(TAG, "onDestroy finished") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt b/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt new file mode 100644 index 0000000..4343cb2 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt @@ -0,0 +1,58 @@ +package com.example.sshproxy + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator + +class HttpCustomActivity : AppCompatActivity() { + + private lateinit var viewPager: ViewPager2 + private lateinit var tabLayout: TabLayout + + private val statusReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val status = intent?.getStringExtra("status") ?: return + val color = when (status) { + "Connected" -> android.R.color.holo_green_dark + "Disconnected" -> android.R.color.holo_red_dark + "Connecting..." -> android.R.color.holo_orange_dark + else -> android.R.color.holo_red_dark + } + val configFragment = supportFragmentManager.findFragmentByTag("f0") as? ConfigFragment + configFragment?.updateStatus(status, color) + // No need to set disconnect button state – it's a single toggle managed by ConfigFragment itself + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_http_custom) + + viewPager = findViewById(R.id.viewPager) + tabLayout = findViewById(R.id.tabLayout) + + val adapter = ViewPagerAdapter(this) + viewPager.adapter = adapter + + TabLayoutMediator(tabLayout, viewPager) { tab, position -> + tab.text = when (position) { + 0 -> "Config" + else -> "Logs" + } + }.attach() + + LocalBroadcastManager.getInstance(this).registerReceiver(statusReceiver, IntentFilter("VPN_STATUS")) + } + + override fun onDestroy() { + super.onDestroy() + LocalBroadcastManager.getInstance(this).unregisterReceiver(statusReceiver) + } +} diff --git a/app/src/main/java/com/example/sshproxy/LocalSocks5Proxy.kt b/app/src/main/java/com/example/sshproxy/LocalSocks5Proxy.kt new file mode 100644 index 0000000..aeeec7e --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/LocalSocks5Proxy.kt @@ -0,0 +1,147 @@ +package com.example.sshproxy + +import com.jcraft.jsch.ChannelDirectTCPIP +import com.jcraft.jsch.Session +import java.io.InputStream +import java.io.OutputStream +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException + +/** + * Simple SOCKS5 proxy server that forwards connections over an SSH session + * using JSch's ChannelDirectTCPIP. + */ +class LocalSocks5Proxy(private val sshSession: Session) { + private var serverSocket: ServerSocket? = null + private var isRunning = false + private var port = 0 + + fun start(): Int { + serverSocket = ServerSocket(0) + port = serverSocket!!.localPort + isRunning = true + LogManager.addLog("[SOCKS5] Proxy started on port $port") + Thread { acceptLoop() }.start() + return port + } + + private fun acceptLoop() { + while (isRunning) { + try { + val client = serverSocket!!.accept() + Thread { handleClient(client) }.start() + } catch (e: Exception) { + if (isRunning) { + LogManager.addLog("[SOCKS5] Accept error: ${e.message}") + } + } + } + } + + private fun handleClient(client: Socket) { + try { + val input = client.getInputStream() + val output = client.getOutputStream() + + // SOCKS5 handshake (no authentication) + val version = input.read() + if (version != 0x05) { + client.close() + return + } + val nmethods = input.read() + repeat(nmethods) { input.read() } + output.write(byteArrayOf(0x05, 0x00)) + output.flush() + + // Parse CONNECT request + val cmd = input.read() + if (cmd != 0x01) { // CONNECT only + client.close() + return + } + input.read() // RSV + val addrType = input.read() + val destHost = when (addrType) { + 0x01 -> { // IPv4 + val ip = ByteArray(4) + input.read(ip) + ip.joinToString(".") { (it.toInt() and 0xFF).toString() } + } + 0x03 -> { // Domain name + val len = input.read() + val domain = ByteArray(len) + input.read(domain) + String(domain) + } + 0x04 -> { // IPv6 (skip for simplicity) + val ip = ByteArray(16) + input.read(ip) + // You can implement a proper IPv6 string conversion if needed + "::1" + } + else -> { + client.close() + return + } + } + val destPort = (input.read() shl 8) or input.read() + + LogManager.addLog("[SOCKS5] Connecting to $destHost:$destPort") + + // Open SSH direct-tcpip channel + val channel = sshSession.openChannel("direct-tcpip") as ChannelDirectTCPIP + channel.setHost(destHost) + channel.setPort(destPort) + channel.connect() + + // Send success response (BND.ADDR = 0.0.0.0:0) + output.write(byteArrayOf(0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)) + output.flush() + + // Relay data bidirectionally + val clientInput = client.getInputStream() + val clientOutput = client.getOutputStream() + val channelInput = channel.getInputStream() + val channelOutput = channel.getOutputStream() + + relay(clientInput, channelOutput, "client->ssh") + relay(channelInput, clientOutput, "ssh->client") + + // Wait for threads to finish + clientInput.close() + channel.disconnect() + client.close() + + } catch (e: Exception) { + if (!(e is SocketException && e.message?.contains("Socket closed") == true)) { + LogManager.addLog("[SOCKS5] Error: ${e.message}") + } + } + } + + private fun relay(input: InputStream, output: OutputStream, name: String) { + Thread { + try { + val buffer = ByteArray(8192) + while (true) { + val len = input.read(buffer) + if (len <= 0) break + output.write(buffer, 0, len) + output.flush() + } + } catch (_: Exception) { + // closed + } + }.start() + } + + fun stop() { + isRunning = false + try { + serverSocket?.close() + } catch (_: Exception) {} + LogManager.addLog("[SOCKS5] Proxy stopped") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/LogManager.kt b/app/src/main/java/com/example/sshproxy/LogManager.kt new file mode 100644 index 0000000..fac9b42 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/LogManager.kt @@ -0,0 +1,50 @@ +package com.example.sshproxy + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.CopyOnWriteArrayList + +object LogManager { + private const val MAX_LOG_LINES = 150 + private val logs = CopyOnWriteArrayList() + private var listeners = mutableListOf() + + interface LogUpdateListener { + fun onLogUpdated() + } + + fun addLog(message: String) { + val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) + val entry = "[$timestamp] $message" + logs.add(entry) + if (logs.size > MAX_LOG_LINES) { + val toRemove = logs.size - MAX_LOG_LINES + repeat(toRemove) { + if (logs.isNotEmpty()) logs.removeAt(0) + } + } + notifyListeners() + } + + fun getLogs(): List = logs + + fun clearLogs() { + logs.clear() + notifyListeners() + } + + fun registerListener(listener: LogUpdateListener) { + listeners.add(listener) + } + + fun unregisterListener(listener: LogUpdateListener) { + listeners.remove(listener) + } + + private fun notifyListeners() { + for (listener in listeners) { + try { listener.onLogUpdated() } catch (_: Exception) {} + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/LogsFragment.kt b/app/src/main/java/com/example/sshproxy/LogsFragment.kt new file mode 100644 index 0000000..c1f3cca --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/LogsFragment.kt @@ -0,0 +1,86 @@ +package com.example.sshproxy + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class LogsFragment : Fragment() { + + private lateinit var logText: TextView + private var copyFab: FloatingActionButton? = null + private var shareFab: FloatingActionButton? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_logs, container, false) + + logText = view.findViewById(R.id.logText) + + try { + copyFab = view.findViewById(R.id.copyLogsButton) + shareFab = view.findViewById(R.id.shareLogsButton) + + copyFab?.setOnClickListener { copyLogs() } + shareFab?.setOnClickListener { shareLogs() } + } catch (e: Exception) { + // If FABs are missing, just ignore – the text view still works + e.printStackTrace() + } + + logText.setOnLongClickListener { + copyLogs() + true + } + + lifecycleScope.launch { + while (true) { + val logs = LogManager.getLogs() + logText.text = logs.joinToString("\n") + delay(500) + } + } + + return view + } + + override fun onResume() { + super.onResume() + val logs = LogManager.getLogs() + logText.text = logs.joinToString("\n") + } + + private fun copyLogs() { + val text = logText.text.toString() + if (text.isNotEmpty()) { + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("logs", text)) + Toast.makeText(requireContext(), "Logs copied", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(requireContext(), "No logs", Toast.LENGTH_SHORT).show() + } + } + + private fun shareLogs() { + val text = logText.text.toString() + if (text.isNotEmpty()) { + val sendIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + startActivity(Intent.createChooser(sendIntent, "Share logs via")) + } else { + Toast.makeText(requireContext(), "No logs", Toast.LENGTH_SHORT).show() + } + } +} diff --git a/app/src/main/java/com/example/sshproxy/MainActivity.kt b/app/src/main/java/com/example/sshproxy/MainActivity.kt deleted file mode 100644 index 2618e24..0000000 --- a/app/src/main/java/com/example/sshproxy/MainActivity.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.example.sshproxy - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import com.example.sshproxy.databinding.ActivityMainNewBinding -import com.example.sshproxy.ui.home.HomeFragment -import com.example.sshproxy.ui.keys.KeysFragment -import com.example.sshproxy.ui.servers.ServersFragment -import com.example.sshproxy.ui.settings.SettingsFragment -import com.example.sshproxy.ui.setup.* -import com.example.sshproxy.ui.dialogs.HostKeyChangeDialog -import com.example.sshproxy.security.SecurityNotificationManager -import com.google.android.material.navigation.NavigationBarView - -import com.example.sshproxy.data.PreferencesManager -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import android.app.Activity -import android.content.Intent -import android.net.VpnService -import android.os.Build -import androidx.activity.result.contract.ActivityResultContracts -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.ServerRepository -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -class MainActivity : AppCompatActivity() { - private lateinit var binding: ActivityMainNewBinding - private lateinit var setupManager: SetupManager - - override fun onCreate(savedInstanceState: Bundle?) { - val preferencesManager = PreferencesManager(this) - val theme = preferencesManager.getTheme() - - android.util.Log.d("MainActivity", "onCreate - Current theme: $theme") - - // Применяем тему ПЕРЕД вызовом super.onCreate() - applyTheme(theme) - - super.onCreate(savedInstanceState) - - applyLanguage(preferencesManager.getLanguage()) - - binding = ActivityMainNewBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupManager = SetupManager(this) - - // Auto-select server if there is only one - lifecycleScope.launch { - val serverRepository = ServerRepository(this@MainActivity) - val servers = serverRepository.getAllServers().first() - if (servers.size == 1) { - preferencesManager.setActiveServerId(servers.first().id) - } - } - - // Handle security alerts from notifications - handleSecurityIntent() - - // Check if first launch - if (setupManager.isFirstLaunch()) { - startSetupFlow() - } else { - setupNavigation() - } - } - - private fun applyTheme(theme: String) { - android.util.Log.d("MainActivity", "Applying theme: $theme") - when (theme) { - "light" -> { - android.util.Log.d("MainActivity", "Setting MODE_NIGHT_NO") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - "dark" -> { - android.util.Log.d("MainActivity", "Setting MODE_NIGHT_YES") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - } - else -> { - android.util.Log.d("MainActivity", "Setting MODE_NIGHT_FOLLOW_SYSTEM") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - } - } - } - - private fun applyLanguage(language: String) { - val localeList = if (language == "system") { - LocaleListCompat.getEmptyLocaleList() - } else { - LocaleListCompat.forLanguageTags(language) - } - AppCompatDelegate.setApplicationLocales(localeList) - } - - private fun setupNavigation() { - binding.bottomNavigation.setOnItemSelectedListener(NavigationBarView.OnItemSelectedListener { item -> - when (item.itemId) { - R.id.navigation_home -> { - loadFragment(HomeFragment()) - true - } - R.id.navigation_servers -> { - loadFragment(ServersFragment()) - true - } - R.id.navigation_keys -> { - loadFragment(KeysFragment()) - true - } - R.id.navigation_settings -> { - loadFragment(SettingsFragment()) - true - } - else -> false - } - }) - - // Load home fragment by default - if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { - binding.bottomNavigation.selectedItemId = R.id.navigation_home - } - } - - - private fun loadFragment(fragment: Fragment) { - supportFragmentManager.beginTransaction() - .replace(R.id.fragmentContainer, fragment) - .commit() - } - - fun onSetupComplete() { - setupNavigation() - } - - private fun startSetupFlow() { - // Hide bottom navigation during setup - binding.bottomNavigation.visibility = android.view.View.GONE - loadFragment(WelcomeFragment()) - } - - fun navigateToKeySetup() { - loadFragment(KeyChoiceFragment()) - } - - fun navigateToKeyGeneration() { - loadFragment(KeyGenerationFragment()) - } - - - fun navigateToAddServer() { - loadFragment(AddFirstServerFragment()) - } - - fun completeSetup() { - setupManager.completeSetup() - // Show bottom navigation and go to main app - binding.bottomNavigation.visibility = android.view.View.VISIBLE - setupNavigation() - // Load the home fragment immediately after setup - loadFragment(HomeFragment()) - // Set the home item as selected in bottom navigation - binding.bottomNavigation.selectedItemId = R.id.navigation_home - } - - fun showSetupFlow() { - loadFragment(KeySetupFragment()) - } - - fun navigateToHome() { - setupNavigation() - } - - private fun handleSecurityIntent() { - val showSecurityAlert = intent.getBooleanExtra("show_security_alert", false) - if (showSecurityAlert) { - val hostname = intent.getStringExtra("hostname") ?: return - val port = intent.getIntExtra("port", 22) - - // Clear the security notification since user opened the app - SecurityNotificationManager(this).clearSecurityNotifications() - - // Show the host key change dialog - // For now, we'll just log it since we need the actual fingerprints - android.util.Log.d("MainActivity", "Host key change detected for $hostname:$port") - // TODO: Get actual fingerprints and show HostKeyChangeDialog - } - } - - private val vpnPermissionLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - // VPN permission granted, start VPN - startVpnFromWidget() - } else { - // VPN permission denied - android.util.Log.d("MainActivity", "VPN permission denied") - } - } - - private fun handleVpnStartIntent() { - android.util.Log.d("MainActivity", "handleVpnStartIntent: action=${intent?.action}") - if (intent?.action == "com.example.sshproxy.ACTION_START_VPN_FROM_WIDGET") { - val serverId = intent.getLongExtra("server_id", -1) - android.util.Log.d("MainActivity", "handleVpnStartIntent: serverId=$serverId") - if (serverId != -1L) { - // Save server ID for later use - getSharedPreferences("ssh_proxy_prefs", MODE_PRIVATE).edit() - .putLong("pending_widget_server_id", serverId) - .apply() - - // Check if VPN permission is needed - val vpnIntent = VpnService.prepare(this) - if (vpnIntent != null) { - // Permission needed - vpnPermissionLauncher.launch(vpnIntent) - } else { - // Permission already granted - startVpnFromWidget() - } - } - } - } - - private fun startVpnFromWidget() { - android.util.Log.d("MainActivity", "startVpnFromWidget called") - val prefs = getSharedPreferences("ssh_proxy_prefs", MODE_PRIVATE) - val serverId = prefs.getLong("pending_widget_server_id", -1) - android.util.Log.d("MainActivity", "startVpnFromWidget: serverId=$serverId") - - if (serverId != -1L) { - // Clear pending server ID and set connecting state - prefs.edit() - .remove("pending_widget_server_id") - .putBoolean("vpn_connecting", true) - .apply() - - // Update widget to show connecting state - val updateIntent = Intent("android.appwidget.action.APPWIDGET_UPDATE") - updateIntent.setPackage(packageName) - sendBroadcast(updateIntent) - - // Start VPN service - val serviceIntent = Intent(this, SshProxyService::class.java).apply { - action = SshProxyService.ACTION_START - putExtra(SshProxyService.EXTRA_SERVER_ID, serverId) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) - } else { - startService(serviceIntent) - } - - // Close activity to return to previous app - finish() - } - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - setIntent(intent) - handleVpnStartIntent() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/SshProxyService.kt b/app/src/main/java/com/example/sshproxy/SshProxyService.kt deleted file mode 100644 index 57b0f31..0000000 --- a/app/src/main/java/com/example/sshproxy/SshProxyService.kt +++ /dev/null @@ -1,1184 +0,0 @@ - -package com.example.sshproxy - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import android.content.pm.PackageManager -import android.net.VpnService -import android.os.IBinder -import android.os.Build -import android.os.ParcelFileDescriptor -import android.util.Log -import androidx.core.app.NotificationCompat -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.security.SecureHostKeyVerifier -import com.example.sshproxy.security.SecurityNotificationManager -import com.example.sshproxy.service.ConnectionHealthMonitor -import com.example.sshproxy.network.PingMonitor -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import net.schmizz.sshj.DefaultConfig -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Factory -import com.example.sshproxy.network.SshAlgorithmManager -import com.example.sshproxy.network.SshAlgorithms -import com.example.sshproxy.data.Server -import net.schmizz.sshj.connection.channel.direct.LocalPortForwarder -import net.schmizz.sshj.connection.channel.direct.Parameters -import net.schmizz.sshj.transport.verification.PromiscuousVerifier -import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile -import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile -import org.bouncycastle.jce.provider.BouncyCastleProvider -import java.io.File -import java.io.IOException -import java.security.Security -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket -import java.net.URL - -class SshProxyService : VpnService() { - private fun updateVpnWidget() { - val intent = Intent("android.appwidget.action.APPWIDGET_UPDATE") - intent.setPackage(packageName) - sendBroadcast(intent) - } - - private fun setVpnRunningPref(isRunning: Boolean) { - val prefs = getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - prefs.edit().putBoolean("vpn_running", isRunning).apply() - updateVpnWidget() - } - - private fun setVpnConnectingPref(isConnecting: Boolean) { - AppLog.log("setVpnConnectingPref: isConnecting=$isConnecting") - val prefs = getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - prefs.edit().putBoolean("vpn_connecting", isConnecting).apply() - - // Управляем морганием виджета - if (isConnecting) { - com.example.sshproxy.widget.VPNStatusWidgetProvider.startBlinking(this) - } else { - com.example.sshproxy.widget.VPNStatusWidgetProvider.stopBlinking(this) - // Дополнительно обновляем виджет после остановки моргания - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - updateVpnWidget() - }, 100) - } - - updateVpnWidget() - } - enum class ConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED, - DISCONNECTING - } - - companion object { - const val ACTION_START = "com.example.sshproxy.START_VPN" - const val ACTION_STOP = "com.example.sshproxy.STOP_VPN" - const val EXTRA_SERVER_ID = "server_id" - private const val NOTIFICATION_ID = 1 - private const val CHANNEL_ID = "ssh_proxy_channel" - private const val TAG = "SshProxyService" - - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) - val connectionState: StateFlow = _connectionState.asStateFlow() - - // Для обратной совместимости - создаем isRunning из connectionState - private val _isRunning = MutableStateFlow(false) - val isRunning: StateFlow = _isRunning.asStateFlow() - - // Connection start time tracking - private val _connectionStartTime = MutableStateFlow(null) - val connectionStartTime: StateFlow = _connectionStartTime.asStateFlow() - - // Expose ping data - private val _currentPingMonitor = MutableStateFlow(null) - val currentPingMonitor: StateFlow = _currentPingMonitor.asStateFlow() - - init { - android.util.Log.d("SshProxyService", "Companion object init") - // Регистрируем BouncyCastle провайдер для поддержки современной криптографии - Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) - Security.addProvider(BouncyCastleProvider()) - } - } - - private var sshClient: SSHClient? = null - private var localPortForwarder: LocalPortForwarder? = null - private var vpnInterface: ParcelFileDescriptor? = null - private var connectionJob: Job? = null - private var serverSocket: ServerSocket? = null - private var hostKeyVerifier: SecureHostKeyVerifier? = null - private var securityNotificationManager: SecurityNotificationManager? = null - private var connectionMonitor: ConnectionHealthMonitor? = null - private var pingMonitor: PingMonitor? = null - private lateinit var serviceScope: CoroutineScope - private val algorithmManager = SshAlgorithmManager() - - private lateinit var preferencesManager: PreferencesManager - private lateinit var serverRepository: ServerRepository - private var currentServerId: Long = -1L - private var activeNetworks: Array? = null - private var networkCallback: ConnectivityManager.NetworkCallback? = null - private var lastActiveNetwork: Network? = null - private var isVpnTemporarilyDisabled = false - private var isVpnRecreating = false - private var lastNetworkType: String? = null // "wifi" или "cellular" - - - override fun onCreate() { - super.onCreate() // Corrected: Added missing semicolon - AppLog.log("SshProxyService.onCreate") - serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - createNotificationChannel() - - preferencesManager = PreferencesManager(this) - serverRepository = ServerRepository(this) - - // Initialize security notification manager - securityNotificationManager = SecurityNotificationManager(this) - - // Initialize host key verifier - hostKeyVerifier = SecureHostKeyVerifier(this) - - // Initialize connection monitor - connectionMonitor = ConnectionHealthMonitor( - healthCheckIntervalMs = preferencesManager.getHealthCheckInterval(), - maxReconnectAttempts = preferencesManager.getMaxReconnectAttempts(), - initialBackoffMs = preferencesManager.getInitialBackoffMs(), - maxBackoffMs = preferencesManager.getMaxBackoffMs(), - backoffMultiplier = preferencesManager.getBackoffMultiplier().toDouble() - ) - - // Observe connection state changes - serviceScope.launch { - connectionMonitor?.connectionState?.collectLatest { - when (it) { - ConnectionHealthMonitor.ConnectionState.RECONNECTING -> { - Log.d(TAG, "Attempting to reconnect...") - updateNotification("Reconnecting...") - } - ConnectionHealthMonitor.ConnectionState.CONNECTED -> { - Log.i(TAG, "Connection restored") - updateNotification("Connected") - } - ConnectionHealthMonitor.ConnectionState.FAILED -> { - Log.e(TAG, "Failed to reconnect after maximum attempts") - updateNotification("Connection failed") - stopSelf() - } - else -> {} - } - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - AppLog.log("SshProxyService.onStartCommand: ${intent?.action}") - when (intent?.action) { - ACTION_START -> { - _connectionState.value = ConnectionState.CONNECTING - _isRunning.value = false // Connecting != Running - setVpnConnectingPref(true) - val serverId = intent.getLongExtra(EXTRA_SERVER_ID, -1) - if (serverId != -1L) { - currentServerId = serverId - startForeground(NOTIFICATION_ID, createNotification("Connecting...")) - connectionJob = serviceScope.launch { - startConnection(serverId) - } - } - } - ACTION_STOP -> { - stopVpn() - } - } - return START_NOT_STICKY - } - - private suspend fun startConnection(serverId: Long) { - withContext(Dispatchers.IO) { - try { - AppLog.log("Starting connection...") - val server = serverRepository.getServerById(serverId) - if (server == null) { - Log.e(TAG, "Server not found") - AppLog.log("Error: Server not found for ID: $serverId") - stopSelf() - return@withContext - } - val prefs = getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - prefs.edit().putString("active_server_host", server.host).apply() - // Проверяем, что VPN еще НЕ активен - val currentVpnStatus = if (vpnInterface != null) "ACTIVE" else "INACTIVE" - AppLog.log("Current VPN status: $currentVpnStatus") - // Получаем активные сети для обхода VPN - val connectivityManager = getSystemService(ConnectivityManager::class.java) - activeNetworks = connectivityManager?.let { cm -> - try { - val activeNetwork = cm.activeNetwork - if (activeNetwork != null) { - val capabilities = cm.getNetworkCapabilities(activeNetwork) - val hasInternet = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - val notVpn = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) != true - val validated = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true - AppLog.log("Active network: $activeNetwork, hasInternet: $hasInternet, notVPN: $notVpn, validated: $validated") - if (hasInternet && notVpn && validated) { - arrayOf(activeNetwork) - } else { - AppLog.log("Active network is not suitable for SSH bypass") - arrayOf() - } - } else { - AppLog.log("No active network found") - arrayOf() - } - } catch (e: Exception) { - AppLog.log("Error getting networks: ${e.message}") - arrayOf() - } - } ?: arrayOf() - AppLog.log("Found ${activeNetworks?.size ?: 0} active non-VPN networks") - - AppLog.log("Connecting to ${server.name} (${server.host}:${server.port}) as ${server.username}") - AppLog.log("Server SSH key ID: ${server.sshKeyId}") - AppLog.log("SSH connect timeout: 30000ms") - - // SSH подключение с проверкой host key - AppLog.log("Initializing SSH client...") - sshClient = createOptimizedSshClient(server).apply { - // Use our secure host key verifier instead of promiscuous - addHostKeyVerifier(hostKeyVerifier!!) - AppLog.log("Connecting to ${server.host}:${server.port}...") - - try { - connect(server.host, server.port) - AppLog.log("Connected. Authenticating as '${server.username}'...") - - // SSH подключение установлено - AppLog.log("SSH transport connection established successfully") - - } catch (e: Exception) { - // Check if this is a host key verification failure - if (e.message?.contains("host key", ignoreCase = true) == true || - e.message?.contains("verification", ignoreCase = true) == true) { - AppLog.log("Host key verification failed - showing security notification") - securityNotificationManager?.showHostKeyChangeNotification(server.host, server.port) - } - throw e - } - - // Используем ключ сервера, если есть, иначе активный ключ - // Если у сервера нет привязанного ключа (null), функция автоматически использует активный - val keyFile = resolvePrivateKeyFile(server.sshKeyId) - ?: throw IOException("SSH key not found. Please generate one.") - - // Try PKCS8KeyFile first (RSA/ECDSA), иначе показать ошибку для Ed25519 - val keyProvider = try { - val pkcs8KeyFile = PKCS8KeyFile() - pkcs8KeyFile.init(keyFile) - pkcs8KeyFile - } catch (e: Exception) { - AppLog.log("PKCS8KeyFile failed: ${e.message}") - throw IOException("Unable to load SSH key: ${e.message}. Ed25519 keys are not supported, use ECDSA or RSA.") - } - - authPublickey(server.username, keyProvider) - AppLog.log("SSH authentication successful.") - } - - // Автодетекция и сохранение оптимальных алгоритмов для первого подключения - if (server.preferredCipher == null && server.preferredKex == null && server.preferredMac == null) { - serviceScope.launch { - saveOptimalAlgorithmsForServer(server) - } - } - - // 2. Port forwarding - AppLog.log("Setting up port forwarding...") - setupPortForwarding(server) - - // 3. Запускаем мониторинг сети - AppLog.log("Starting network monitoring...") - // Инициализируем текущую активную сеть - val connManager = getSystemService(ConnectivityManager::class.java) - lastActiveNetwork = connManager?.activeNetwork - lastNetworkType = getNetworkType(connManager, lastActiveNetwork) - AppLog.log("Initial active network: $lastActiveNetwork, type: $lastNetworkType") - startNetworkMonitoring() - - // 4. VPN после стабильного SSH - AppLog.log("Setting up VPN...") - setupVpn() - - withContext(Dispatchers.Main) { - _connectionState.value = ConnectionState.CONNECTED - _isRunning.value = true - _connectionStartTime.value = System.currentTimeMillis() - updateNotification("Connected to ${server.name}") - setVpnConnectingPref(false) - setVpnRunningPref(true) - AppLog.log("Connection fully established at ${_connectionStartTime.value}") - - // Start ping monitoring with aggressive connection issue detection - pingMonitor?.stopMonitoring() - pingMonitor = PingMonitor( - server.host, - server.port, - onConnectionIssue = { - AppLog.log("Quick connection issue detected: $it consecutive ping failures") - // Trigger immediate connection check - serviceScope.launch { - if (!checkSshConnection()) { - AppLog.log("SSH connection confirmed dead, triggering reconnection") - connectionMonitor?.onConnectionLost() - } - } - } - ) - pingMonitor?.startMonitoring() - _currentPingMonitor.value = pingMonitor - AppLog.log("Started aggressive ping monitoring for ${server.host}:${server.port}") - } - - connectionMonitor?.onConnectionEstablished() - if (preferencesManager.isAutoReconnectEnabled()) { - connectionMonitor?.startMonitoring( - scope = serviceScope, - isConnectionAlive = { checkSshConnection() }, - onReconnect = { reconnectSsh(serverId) } - ) - } - } catch (e: Exception) { - AppLog.log("SshProxyService.startConnection catch: ${e.message}") - Log.e(TAG, "Connection failed: ${e.message}", e) - connectionMonitor?.onConnectionLost() - withContext(Dispatchers.Main) { - _connectionState.value = ConnectionState.DISCONNECTED - _isRunning.value = false - _connectionStartTime.value = null - setVpnConnectingPref(false) - updateNotification("Connection failed") - stopSelf() - } - } - } - } - - private fun setupPortForwarding(server: com.example.sshproxy.data.Server) { - AppLog.log("Binding to local port ${server.httpProxyPort}...") - serverSocket = ServerSocket(server.httpProxyPort, 50, InetAddress.getByName("127.0.0.1")) - val params = Parameters("127.0.0.1", server.httpProxyPort, "127.0.0.1", 8118) - localPortForwarder = sshClient?.newLocalPortForwarder(params, serverSocket) - - // Start forwarding in background - serviceScope.launch(Dispatchers.IO) { - try { - AppLog.log("Port forwarder listening...") - localPortForwarder?.listen() - AppLog.log("Port forwarder stopped.") - } catch (e: Exception) { - if (e is InterruptedException || e.message?.contains("Socket closed") == true) { - Log.i(TAG, "Port forwarding stopped intentionally.") - AppLog.log("Port forwarding stopped.") - } else { - Log.e(TAG, "Port forwarding error: ${e.message}", e) - AppLog.log("Error: Port forwarding failed - ${e.message}") - } - } - } - } - - private suspend fun setupVpn() { - val server = serverRepository.getServerById(currentServerId) ?: throw IOException("Server not found") - - val builder = Builder() - .setSession("SSH Proxy") - .addAddress("10.0.0.2", 32) // IPv4 - .addAddress("fd00::1", 128) // IPv6 local address - .addDnsServer("8.8.8.8") // IPv4 DNS - .addDnsServer("2001:4860:4860::8888") // IPv6 Google DNS - .setMtu(1500) - - // setHttpProxy доступен только с API 29 - if (Build.VERSION.SDK_INT >= 29) { - builder.setHttpProxy(android.net.ProxyInfo.buildDirectProxy("127.0.0.1", server.httpProxyPort)) - } - - // Настраиваем обход VPN для SSH соединения - if (activeNetworks != null && activeNetworks!!.isNotEmpty()) { - AppLog.log("Setting underlying networks to bypass VPN") - builder.setUnderlyingNetworks(activeNetworks) - } - - // Защита SSH соединения от VPN маршрутизации - try { - // Используем уже подключенный SSH клиент для получения IP - val sshServerIp = if (server.host.matches(Regex("\\d+\\.\\d+\\.\\d+\\.\\d+"))) { - // Уже IP адрес - server.host - } else { - // Разрешаем имя хоста - withContext(Dispatchers.IO) { - InetAddress.getByName(server.host).hostAddress - } - } - - AppLog.log("Excluding SSH server $sshServerIp from VPN routes") - - // ПРОСТОЕ РЕШЕНИЕ: используем setUnderlyingNetworks вместо сложной маршрутизации - if (activeNetworks != null && activeNetworks!!.isNotEmpty()) { - AppLog.log("Using setUnderlyingNetworks for SSH bypass") - builder.setUnderlyingNetworks(activeNetworks) - } else { - // Фоллбек - добавляем простые маршруты для IPv4 и IPv6 - AppLog.log("Using split routing fallback") - // IPv4 routes - builder.addRoute("1.0.0.0", 8) // 1.0.0.0/8 - builder.addRoute("2.0.0.0", 7) // 2.0.0.0/7 - builder.addRoute("4.0.0.0", 6) // 4.0.0.0/6 - builder.addRoute("8.0.0.0", 5) // 8.0.0.0/5 - builder.addRoute("16.0.0.0", 4) // 16.0.0.0/4 - builder.addRoute("32.0.0.0", 3) // 32.0.0.0/3 - builder.addRoute("64.0.0.0", 2) // 64.0.0.0/2 - builder.addRoute("128.0.0.0", 1) // 128.0.0.0/1 - // IPv6 routes - route all IPv6 traffic through VPN - builder.addRoute("2000::", 3) // Global IPv6 unicast (2000::/3) - builder.addRoute("fd00::", 8) // Unique local addresses (fd00::/8) - builder.addRoute("fe80::", 10) // Link-local addresses (fe80::/10) - // Исключаем блок с SSH сервером - } - - } catch (e: Exception) { - AppLog.log("Error setting up VPN routes: ${e.message}") - // Минимальная маршрутизация для тестирования - builder.addRoute("8.8.8.8", 32) // IPv4 Google DNS для теста - builder.addRoute("2001:4860:4860::8888", 128) // IPv6 Google DNS для теста - } - - // Per-app split tunneling (allowlist mode) - val allowedApps = preferencesManager.getSplitTunnelingApps() - if (allowedApps.isNotEmpty()) { - // Always include own package so IP checker and connection health check - // work through the VPN tunnel, not bypassing it - try { - builder.addAllowedApplication(packageName) - } catch (e: PackageManager.NameNotFoundException) { - AppLog.log("Split tunneling: could not add own package $packageName") - } - for (pkg in allowedApps) { - try { - builder.addAllowedApplication(pkg) - } catch (e: PackageManager.NameNotFoundException) { - AppLog.log("Split tunneling: skipping unknown package $pkg") - } - } - AppLog.log("Split tunneling: ${allowedApps.size} user apps + own package in allowlist") - } - - vpnInterface = builder.establish() - if (vpnInterface == null) { - throw IOException("Failed to establish VPN") - } - AppLog.log("VPN established with SSH server protection") - } - - private fun addAllRoutesExcept(builder: Builder, excludeIp: String) { - // Разбиваем 0.0.0.0/0 на более мелкие блоки, исключая SSH сервер - val excludeAddr = InetAddress.getByName(excludeIp) - val excludeBytes = excludeAddr.address - - if (excludeBytes.size == 4) { // IPv4 - // Добавляем маршруты, исключая /32 блок SSH сервера - for (i in 0..255) { - if (i.toByte() != excludeBytes[0]) { - builder.addRoute("$i.0.0.0", 8) - } else { - // Разбиваем этот /8 блок дальше - for (j in 0..255) { - if (j.toByte() != excludeBytes[1]) { - builder.addRoute("$i.$j.0.0", 16) - } else { - // Разбиваем этот /16 блок дальше - for (k in 0..255) { - if (k.toByte() != excludeBytes[2]) { - builder.addRoute("$i.$j.$k.0", 24) - } else { - // Добавляем все адреса в /24 блоке кроме SSH сервера - for (l in 0..255) { - if (l.toByte() != excludeBytes[3]) { - builder.addRoute("$i.$j.$k.$l", 32) - } - } - } - } - } - } - } - } - } - } - - private suspend fun checkSshConnection(): Boolean { - return withContext(Dispatchers.IO) { - try { - val client = sshClient - if (client == null || !client.isConnected || !client.isAuthenticated) { - return@withContext false - } - // sshj's isConnected may return true even when the underlying TCP - // connection has silently dropped (server-side timeout, NAT expiry, etc). - // Test the actual proxy port to catch those silent failures. - val server = serverRepository.getServerById(currentServerId) - if (server != null) { - try { - Socket().use { socket -> - socket.soTimeout = 2000 - socket.connect(InetSocketAddress("127.0.0.1", server.httpProxyPort), 2000) - } - true - } catch (e: Exception) { - AppLog.log("Health check: proxy port ${server.httpProxyPort} not responding — tunnel broken") - false - } - } else { - client.isConnected && client.isAuthenticated - } - } catch (e: Exception) { - Log.w(TAG, "Health check failed: ${e.message}") - false - } - } - } - - private suspend fun reconnectSsh(serverId: Long): Boolean { - return withContext(Dispatchers.IO) { - try { - cleanupSshConnection() - delay(500) - - val server = serverRepository.getServerById(serverId) ?: return@withContext false - Log.d(TAG, "Reconnecting to ${server.host}...") - - sshClient = createOptimizedSshClient(server).apply { - addHostKeyVerifier(hostKeyVerifier!!) - connectTimeout = 30000 - connect(server.host, server.port) - - val keyFile = resolvePrivateKeyFile(server.sshKeyId) - ?: throw IOException("SSH key not found for reconnect.") - val pk = PKCS8KeyFile().apply { init(keyFile) } - authPublickey(server.username, pk) - } - - setupPortForwarding(server) - Log.i(TAG, "Reconnection successful") - true - } catch (e: Exception) { - Log.e(TAG, "Reconnection failed: ${e.message}") - false - } - } - } - - private fun cleanupSshConnection() { - try { - localPortForwarder?.close() - serverSocket?.close() - sshClient?.disconnect() - } catch (e: Exception) { - Log.w(TAG, "Error during cleanup: ${e.message}") - } finally { - localPortForwarder = null - serverSocket = null - sshClient = null - } - } - - private fun stopVpn() { - AppLog.log("SshProxyService.stopVpn") - _connectionState.value = ConnectionState.DISCONNECTING - _isRunning.value = false - setVpnConnectingPref(false) - AppLog.log("Disconnecting...") - connectionJob?.cancel() - connectionMonitor?.stopMonitoring() - cleanupSshConnection() - - vpnInterface?.close() - vpnInterface = null - stopNetworkMonitoring() - - _connectionState.value = ConnectionState.DISCONNECTED - _connectionStartTime.value = null - setVpnConnectingPref(false) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - private suspend fun resolvePrivateKeyFile(serverSshKeyId: String? = null): File? { - return withContext(Dispatchers.IO) { - // Добавим подробные логи для отладки - AppLog.log("resolvePrivateKeyFile called, serverSshKeyId=$serverSshKeyId") - val activeKeyId = preferencesManager.getActiveKeyId() - AppLog.log("Active key from preferences: $activeKeyId") - Log.d(TAG, "resolvePrivateKeyFile: serverSshKeyId=$serverSshKeyId, activeKeyId=$activeKeyId") - - // Используем ключ сервера, если указан, иначе активный ключ - val keyId = serverSshKeyId ?: activeKeyId - AppLog.log("Using SSH key ID: $keyId ${if (serverSshKeyId != null) "(server-specific)" else "(active)"}") - - if (keyId != null) { - try { - val keyRepository = KeyRepository(this@SshProxyService) - - // Get decrypted private key content - val privateKey = keyRepository.getPrivateKey(keyId) - if (privateKey != null) { - // Create temporary PEM file with decrypted content - val tempFile = File.createTempFile("ssh_key_$keyId", ".pem", cacheDir) - tempFile.deleteOnExit() - - // Convert PrivateKey to PEM format and write to temp file - val pemContent = convertPrivateKeyToPem(privateKey) - tempFile.writeText(pemContent) - - AppLog.log("Created temporary decrypted key file: ${tempFile.absolutePath}") - AppLog.log("Temp key file size: ${tempFile.length()} bytes") - AppLog.log("Found SSH key: $keyId") - - return@withContext tempFile - } else { - Log.e(TAG, "Failed to decrypt private key for ID $keyId") - AppLog.log("Error: Failed to decrypt private key for ID $keyId") - } - } catch (e: Exception) { - Log.e(TAG, "Error resolving private key: ${e.message}", e) - AppLog.log("Error resolving private key: ${e.message}") - } - } else { - Log.w(TAG, "No SSH key ID specified.") - AppLog.log("Warning: No SSH key specified.") - } - - Log.e(TAG, "No private key file found.") - AppLog.log("Error: No private key file found.") - return@withContext null - } - } - - private fun convertPrivateKeyToPem(privateKey: java.security.PrivateKey): String { - val stringWriter = java.io.StringWriter() - org.bouncycastle.util.io.pem.PemWriter(stringWriter).use { pemWriter -> - pemWriter.writeObject(org.bouncycastle.util.io.pem.PemObject("PRIVATE KEY", privateKey.encoded)) - } - return stringWriter.toString() - } - - private fun startNetworkMonitoring() { - val connectivityManager = getSystemService(ConnectivityManager::class.java) - if (connectivityManager != null) { - val networkRequest = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - .build() - - networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - AppLog.log("Network available: $network") - handleNetworkChange(network, isAvailable = true) - } - - override fun onLost(network: Network) { - AppLog.log("Network lost: $network") - handleNetworkChange(network, isAvailable = false) - } - - override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { - // Логируем, но не реагируем на мелкие изменения - val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - AppLog.log("Network capabilities changed: $network (internet=$hasInternet, wifi=$isWifi, cellular=$isCellular)") - } - } - - connectivityManager.registerNetworkCallback(networkRequest, networkCallback!!) - AppLog.log("Network monitoring started") - } - } - - private fun stopNetworkMonitoring() { - networkCallback?.let { callback -> - val connectivityManager = getSystemService(ConnectivityManager::class.java) - connectivityManager?.unregisterNetworkCallback(callback) - networkCallback = null - AppLog.log("Network monitoring stopped") - } - } - - private fun updateActiveNetworks() { - val connectivityManager = getSystemService(ConnectivityManager::class.java) - val newActiveNetworks = connectivityManager?.let { cm -> - try { - // Получаем активную сеть и её alternatives - val validNetworks = mutableListOf() - - // Ищем базовые сети (не VPN) среди всех доступных - @Suppress("DEPRECATION") - val allNetworks = cm.allNetworks - for (network in allNetworks) { - val capabilities = cm.getNetworkCapabilities(network) - if (capabilities != null) { - val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val notVpn = !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - - AppLog.log("Checking network $network: internet=$hasInternet, notVPN=$notVpn, wifi=$isWifi, cellular=$isCellular") - - if (hasInternet && notVpn && (isWifi || isCellular)) { - validNetworks.add(network) - AppLog.log("Added valid network: $network") - } - } - } - - // Всегда используем только активную сеть, если она валидна - val activeNetwork = cm.activeNetwork - if (activeNetwork != null) { - val capabilities = cm.getNetworkCapabilities(activeNetwork) - if (capabilities != null) { - val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val notVpn = !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - - if (hasInternet && notVpn && (isWifi || isCellular)) { - AppLog.log("Using only active network: $activeNetwork") - validNetworks.clear() - validNetworks.add(activeNetwork) - } else { - AppLog.log("Active network $activeNetwork is not suitable (internet=$hasInternet, notVPN=$notVpn, wifi=$isWifi, cellular=$isCellular)") - // Если активная сеть не подходит, используем первую подходящую - if (validNetworks.isNotEmpty()) { - val firstValid = validNetworks.first() - AppLog.log("Using first valid network: $firstValid") - validNetworks.clear() - validNetworks.add(firstValid) - } - } - } - } else if (validNetworks.isNotEmpty()) { - // Если нет активной сети, используем первую подходящую - val firstValid = validNetworks.first() - AppLog.log("No active network, using first valid: $firstValid") - validNetworks.clear() - validNetworks.add(firstValid) - } - - AppLog.log("Found ${validNetworks.size} valid networks") - - validNetworks.toTypedArray() - } catch (e: Exception) { - AppLog.log("Error updating networks: ${e.message}") - arrayOf() - } - } ?: arrayOf() - - if (!newActiveNetworks.contentEquals(activeNetworks)) { - activeNetworks = newActiveNetworks - AppLog.log("Active networks updated: ${activeNetworks?.size ?: 0} networks") - - // Обновляем underlying networks для VPN, если он активен - if (vpnInterface != null) { - updateVpnNetworks() - } - } - } - - private fun handleNetworkChange(network: Network, isAvailable: Boolean) { - serviceScope.launch(Dispatchers.IO) { - val connectivityManager = getSystemService(ConnectivityManager::class.java) - val capabilities = connectivityManager.getNetworkCapabilities(network) - val isVpn = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true - if (isVpn) { - AppLog.log("Ignoring VPN network change") - return@launch - } - - try { - val connectivityManager = getSystemService(ConnectivityManager::class.java) - - // Ждем немного чтобы система определилась с активной сетью - delay(1000) - - val currentActiveNetwork = connectivityManager.activeNetwork - val currentNetworkType = getNetworkType(connectivityManager, currentActiveNetwork) - - // Также проверяем тип появившейся/потерянной сети - val changedNetworkType = if (isAvailable) { - getNetworkType(connectivityManager, network) - } else { - null - } - - AppLog.log("Network change: available=$isAvailable, network=$network, networkType=$changedNetworkType") - AppLog.log("Current active: $currentActiveNetwork, type: $currentNetworkType") - AppLog.log("Last active: $lastActiveNetwork, last type: $lastNetworkType") - - // Реагируем на смену ТИПА сети, потерю активной сети, или появление нового типа сети - val shouldReact = (currentNetworkType != lastNetworkType && currentNetworkType != null) || - (currentNetworkType == null && lastNetworkType != null) || - (isAvailable && changedNetworkType != null && changedNetworkType != lastNetworkType) - - if (shouldReact) { - AppLog.log("Should react to network change: currentType=$currentNetworkType, changedType=$changedNetworkType, lastType=$lastNetworkType") - - if (isVpnRecreating) { - AppLog.log("VPN recreation already in progress, ignoring") - return@launch - } - - isVpnRecreating = true - - try { - // Уведомляем UI о начале переподключения - _connectionState.value = ConnectionState.CONNECTING - _isRunning.value = false - updateNotification("Reconnecting...") - - // Отключаем VPN - if (vpnInterface != null) { - AppLog.log("Disabling VPN for network type change") - vpnInterface?.close() - vpnInterface = null - } - - // Ждем стабилизации сети и ищем лучшую доступную сеть - var attempts = 0 - var finalActiveNetwork: Network? = null - var finalNetworkType: String? = null - - while (attempts < 5 && (finalActiveNetwork == null || finalNetworkType == null)) { - delay(1000) - attempts++ - - finalActiveNetwork = connectivityManager.activeNetwork - finalNetworkType = getNetworkType(connectivityManager, finalActiveNetwork) - - AppLog.log("Attempt $attempts: network=$finalActiveNetwork, type=$finalNetworkType") - - // Если активная сеть не подходит, ищем среди всех доступных - if (finalNetworkType == null) { - @Suppress("DEPRECATION") - val allNetworks = connectivityManager.allNetworks - for (availableNetwork in allNetworks) { - val networkType = getNetworkType(connectivityManager, availableNetwork) - val capabilities = connectivityManager.getNetworkCapabilities(availableNetwork) - val hasInternet = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - val notVpn = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) != true - - AppLog.log("Checking alternative network $availableNetwork: type=$networkType, internet=$hasInternet, notVPN=$notVpn") - - if (networkType != null && hasInternet && notVpn) { - AppLog.log("Found suitable alternative network: $availableNetwork ($networkType)") - finalActiveNetwork = availableNetwork - finalNetworkType = networkType - break - } - } - } - } - - AppLog.log("Final result: network=$finalActiveNetwork, type=$finalNetworkType") - - if (finalActiveNetwork != null && finalNetworkType != null) { - lastActiveNetwork = finalActiveNetwork - lastNetworkType = finalNetworkType - isVpnTemporarilyDisabled = false - - // Проверяем SSH - val isSSHAlive = try { - sshClient?.isConnected == true && sshClient?.isAuthenticated == true - } catch (e: Exception) { - false - } - - AppLog.log("SSH alive after network change: $isSSHAlive") - - if (isSSHAlive) { - updateActiveNetworks() - setupVpn() - - // SSH живой, просто переустановили VPN - _connectionState.value = ConnectionState.CONNECTED - _isRunning.value = true - updateNotification("Connected") - } else { - AppLog.log("Reconnecting SSH after network change") - val reconnected = reconnectSsh(currentServerId) - if (reconnected) { - AppLog.log("SSH reconnected successfully, setting up VPN") - updateActiveNetworks() - setupVpn() - - // Обновляем состояние UI - время подключения сохраняется при переподключении - _connectionState.value = ConnectionState.CONNECTED - _isRunning.value = true - updateNotification("Connected") - } else { - AppLog.log("SSH reconnection failed") - _connectionState.value = ConnectionState.DISCONNECTED - _isRunning.value = false - _connectionStartTime.value = null - updateNotification("Connection failed") - } - } - } else { - AppLog.log("No suitable network found after 5 attempts") - isVpnTemporarilyDisabled = true - _connectionState.value = ConnectionState.DISCONNECTED - _isRunning.value = false - _connectionStartTime.value = null - updateNotification("No network available") - } - } finally { - isVpnRecreating = false - } - } - } catch (e: Exception) { - AppLog.log("Error handling network change: ${e.message}") - isVpnRecreating = false - } - } - } - - private fun getNetworkType(connectivityManager: ConnectivityManager?, network: Network?): String? { - if (connectivityManager == null || network == null) return null - - return try { - val capabilities = connectivityManager.getNetworkCapabilities(network) - when { - capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi" - capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "cellular" - else -> null - } - } catch (e: Exception) { - null - } - } - - private fun updateVpnNetworks() { - try { - if (activeNetworks?.isNotEmpty() == true) { - val currentInterface = vpnInterface - if (currentInterface != null) { - // Пересоздаем VPN с новыми underlying networks - serviceScope.launch(Dispatchers.IO) { - AppLog.log("Updating VPN networks due to network change") - setupVpn() - } - } - } - } catch (e: Exception) { - AppLog.log("Error updating VPN networks: ${e.message}") - } - } - - private fun createNotification(status: String): android.app.Notification { - val intent = packageManager.getLaunchIntentForPackage(packageName) - val pendingIntent = if (intent != null) { - PendingIntent.getActivity( - this, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - } else null - - val stopIntent = Intent(this, SshProxyService::class.java).apply { - action = ACTION_STOP - } - val stopPendingIntent = PendingIntent.getService( - this, 1, stopIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("SSH Proxy") - .setContentText(status) - .setSmallIcon(android.R.drawable.ic_lock_lock) - .apply { - if (pendingIntent != null) { - setContentIntent(pendingIntent) - } - } - .addAction(android.R.drawable.ic_delete, "Stop", stopPendingIntent) - .build() - } - - private fun updateNotification(status: String) { - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager?.notify(NOTIFICATION_ID, createNotification(status)) - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "SSH Proxy Service", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "SSH Proxy VPN connection status" - } - - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager?.createNotificationChannel(channel) - } - } - - override fun onDestroy() { - AppLog.log("SshProxyService.onDestroy") - _connectionState.value = ConnectionState.DISCONNECTED - _isRunning.value = false - _connectionStartTime.value = null - setVpnConnectingPref(false) - setVpnRunningPref(false) - connectionMonitor?.stopMonitoring() - pingMonitor?.stopMonitoring() - _currentPingMonitor.value = null - serviceScope.cancel() - cleanupSshConnection() - super.onDestroy() - } - - /** - * Создать SSH клиент с оптимальными алгоритмами - */ - private suspend fun createOptimizedSshClient(server: Server): SSHClient { - return withContext(Dispatchers.IO) { - // Проверяем есть ли сохраненные алгоритмы для этого сервера - val savedAlgorithms = SshAlgorithms( - cipher = server.preferredCipher, - kex = server.preferredKex, - mac = server.preferredMac - ) - - val hasCustomAlgorithms = savedAlgorithms.cipher != null || - savedAlgorithms.kex != null || - savedAlgorithms.mac != null - - val client = if (hasCustomAlgorithms) { - AppLog.log("Using saved algorithms for ${server.name}:") - AppLog.log(" - Cipher: ${savedAlgorithms.cipher}") - AppLog.log(" - KEX: ${savedAlgorithms.kex}") - AppLog.log(" - MAC: ${savedAlgorithms.mac}") - val customConfig = algorithmManager.createCustomConfig(savedAlgorithms) - SSHClient(customConfig) - } else { - AppLog.log("No saved algorithms for ${server.name}, using default SSH config") - AppLog.log("Fast algorithms will be auto-selected and saved after successful connection") - SSHClient() - } - // Protect the SSH socket so it bypasses the VPN tunnel. - // Without this, when the app package is in the split-tunneling allowlist, - // the SSH socket would route through the VPN → proxy → SSH → loop. - applyProtectedSocketFactory(client) - client - } - } - - /** - * Inject a VPN-protected SocketFactory into an SSHClient via reflection. - * Sockets created by this factory call VpnService.protect() before connecting, - * ensuring SSH traffic goes directly to the server without routing through the - * VPN tunnel (which would cause a routing loop when the app is in the allowlist). - */ - private fun applyProtectedSocketFactory(client: SSHClient) { - val factory = object : javax.net.SocketFactory() { - override fun createSocket(): Socket = Socket().also { protect(it) } - override fun createSocket(host: String, port: Int): Socket { - val s = createSocket() - s.connect(InetSocketAddress(host, port)) - return s - } - override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket { - val s = createSocket() - s.bind(InetSocketAddress(localHost, localPort)) - s.connect(InetSocketAddress(host, port)) - return s - } - override fun createSocket(host: InetAddress, port: Int): Socket { - val s = createSocket() - s.connect(InetSocketAddress(host, port)) - return s - } - override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket { - val s = createSocket() - s.bind(InetSocketAddress(localAddress, localPort)) - s.connect(InetSocketAddress(address, port)) - return s - } - } - var clazz: Class<*>? = client.javaClass - while (clazz != null) { - try { - val field = clazz.getDeclaredField("socketFactory") - field.isAccessible = true - field.set(client, factory) - AppLog.log("SSH socket: VPN protection applied") - return - } catch (e: NoSuchFieldException) { - clazz = clazz.superclass - } catch (e: Exception) { - AppLog.log("SSH socket: could not apply VPN protection — ${e.message}") - return - } - } - AppLog.log("SSH socket: socketFactory field not found, SSH may loop through VPN") - } - - /** - * Сохранить быстрые алгоритмы для сервера (после успешного подключения) - */ - private suspend fun saveOptimalAlgorithmsForServer(server: Server) { - try { - AppLog.log("Saving optimal algorithms for ${server.name}...") - - // Получаем быстрые алгоритмы из менеджера - val fastAlgorithms = algorithmManager.getFastAlgorithms() - - AppLog.log("Selected fast algorithms for ${server.name}: cipher=${fastAlgorithms.cipher}, kex=${fastAlgorithms.kex}, mac=${fastAlgorithms.mac}") - - // Сохраняем в базу данных - val updatedServer = server.copy( - preferredCipher = fastAlgorithms.cipher, - preferredKex = fastAlgorithms.kex, - preferredMac = fastAlgorithms.mac - ) - - serverRepository.updateServer(updatedServer) - AppLog.log("Saved fast algorithms for ${server.name}") - - } catch (e: Exception) { - AppLog.log("Failed to save algorithms for ${server.name}: ${e.message}") - Log.w(TAG, "Algorithm saving failed", e) - } - } -} diff --git a/app/src/main/java/com/example/sshproxy/ViewPagerAdapter.kt b/app/src/main/java/com/example/sshproxy/ViewPagerAdapter.kt new file mode 100644 index 0000000..a38c246 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/ViewPagerAdapter.kt @@ -0,0 +1,16 @@ +package com.example.sshproxy + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter + +class ViewPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> ConfigFragment() + else -> LogsFragment() + } + } +} diff --git a/app/src/main/java/com/example/sshproxy/VpnPermissionActivity.kt b/app/src/main/java/com/example/sshproxy/VpnPermissionActivity.kt deleted file mode 100644 index be50716..0000000 --- a/app/src/main/java/com/example/sshproxy/VpnPermissionActivity.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.example.sshproxy - -import android.app.Activity -import android.content.Intent -import android.net.VpnService -import android.os.Build -import android.os.Bundle - -class VpnPermissionActivity : Activity() { - - companion object { - private const val VPN_REQUEST_CODE = 1 - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // No UI, just handle VPN permission - handleVpnStartIntent() - } - - private fun handleVpnStartIntent() { - val serverId = intent.getLongExtra("server_id", -1) - android.util.Log.d("VpnPermissionActivity", "handleVpnStartIntent: serverId=$serverId") - - if (serverId != -1L) { - // Save server ID for later use - getSharedPreferences("ssh_proxy_prefs", MODE_PRIVATE).edit() - .putLong("pending_widget_server_id", serverId) - .apply() - - // Check if VPN permission is needed - val vpnIntent = VpnService.prepare(this) - if (vpnIntent != null) { - // Permission needed - startActivityForResult(vpnIntent, VPN_REQUEST_CODE) - } else { - // Permission already granted - startVpnFromWidget() - finish() - } - } else { - finish() - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (requestCode == VPN_REQUEST_CODE) { - if (resultCode == RESULT_OK) { - // VPN permission granted, start VPN - startVpnFromWidget() - } else { - // VPN permission denied - android.util.Log.d("VpnPermissionActivity", "VPN permission denied") - } - finish() - } - } - - private fun startVpnFromWidget() { - android.util.Log.d("VpnPermissionActivity", "startVpnFromWidget called") - val prefs = getSharedPreferences("ssh_proxy_prefs", MODE_PRIVATE) - val serverId = prefs.getLong("pending_widget_server_id", -1) - android.util.Log.d("VpnPermissionActivity", "startVpnFromWidget: serverId=$serverId") - - if (serverId != -1L) { - // Clear pending server ID and set connecting state - prefs.edit() - .remove("pending_widget_server_id") - .putBoolean("vpn_connecting", true) - .apply() - - // Update widget to show connecting state - val updateIntent = Intent("android.appwidget.action.APPWIDGET_UPDATE") - updateIntent.setPackage(packageName) - sendBroadcast(updateIntent) - - // Start VPN service - val serviceIntent = Intent(this, SshProxyService::class.java).apply { - action = SshProxyService.ACTION_START - putExtra(SshProxyService.EXTRA_SERVER_ID, serverId) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) - } else { - startService(serviceIntent) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/app/src/main/res/layout/activity_logs.xml b/app/src/main/java/com/example/sshproxy/app/src/main/res/layout/activity_logs.xml new file mode 100644 index 0000000..8fe1761 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/app/src/main/res/layout/activity_logs.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/java/com/example/sshproxy/data/ConfigDao.kt b/app/src/main/java/com/example/sshproxy/data/ConfigDao.kt new file mode 100644 index 0000000..fd743b1 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/data/ConfigDao.kt @@ -0,0 +1,14 @@ +package com.example.sshproxy.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query + +@Dao +interface ConfigDao { + @Insert + suspend fun insert(config: ConfigEntity) + + @Query("SELECT * FROM config_table ORDER BY id DESC LIMIT 1") + suspend fun getLatest(): ConfigEntity? +} diff --git a/app/src/main/java/com/example/sshproxy/data/ConfigDatabase.kt b/app/src/main/java/com/example/sshproxy/data/ConfigDatabase.kt new file mode 100644 index 0000000..e4df554 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/data/ConfigDatabase.kt @@ -0,0 +1,32 @@ +package com.example.sshproxy.data + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import android.content.Context + +@Database( + entities = [ConfigEntity::class], + version = 2, + exportSchema = false +) +abstract class ConfigDatabase : RoomDatabase() { + abstract fun configDao(): ConfigDao + + companion object { + @Volatile + private var INSTANCE: ConfigDatabase? = null + + fun getDatabase(context: Context): ConfigDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + ConfigDatabase::class.java, + "config_database" + ).fallbackToDestructiveMigration().build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/com/example/sshproxy/data/ConfigEntity.kt b/app/src/main/java/com/example/sshproxy/data/ConfigEntity.kt new file mode 100644 index 0000000..ded2c40 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/data/ConfigEntity.kt @@ -0,0 +1,25 @@ +package com.example.sshproxy.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "config_table") +data class ConfigEntity( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val sshDetails: String, + val proxyInput: String, + val payload: String, + val splitDelay: Int, + val dnsServer: String, + val pingTarget: String, + val enableCompression: Boolean = true, + val mtu: Int = 1500, + val sendBuffer: Int = 16384, + val receiveBuffer: Int = 32768, + val pingUrl: String = "https://dns.google", + val pingInterval: Int = 2000, + val pingTimeout: Int = 5000, + val alwaysReconnect: Boolean = false, + val followRedirects: Boolean = true // NEW +) diff --git a/app/src/main/java/com/example/sshproxy/data/ConnectionStatus.kt b/app/src/main/java/com/example/sshproxy/data/ConnectionStatus.kt deleted file mode 100644 index 9939f81..0000000 --- a/app/src/main/java/com/example/sshproxy/data/ConnectionStatus.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import com.example.sshproxy.R -import com.example.sshproxy.network.ConnectionQuality -import com.example.sshproxy.network.PingResult -import com.example.sshproxy.network.ServerStats - -data class ConnectionStatus( - val state: ConnectionState = ConnectionState.DISCONNECTED, - val server: Server? = null, - val connectedSince: Long? = null, - val latestPing: PingResult? = null, - val serverStats: ServerStats? = null, - val connectionQuality: ConnectionQuality = ConnectionQuality.UNKNOWN, - val errorMessage: String? = null, - val reconnectionAttempt: Int = 0, - val maxReconnectionAttempts: Int = 0, - val isReconnecting: Boolean = false -) - -enum class ConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED, - DISCONNECTING, - RECONNECTING, - ERROR -} - -// Extension functions for display -fun ConnectionStatus.getDisplayStatus(context: Context): String { - return when (state) { - ConnectionState.DISCONNECTED -> context.getString(R.string.connection_status_disconnected) - ConnectionState.CONNECTING -> context.getString(R.string.connection_status_connecting) - ConnectionState.CONNECTED -> context.getString(R.string.connection_status_connected) - ConnectionState.DISCONNECTING -> context.getString(R.string.connection_status_disconnecting) - ConnectionState.RECONNECTING -> context.getString(R.string.connection_status_reconnecting, reconnectionAttempt, maxReconnectionAttempts) - ConnectionState.ERROR -> context.getString(R.string.connection_status_error) - } -} - -fun ConnectionStatus.getConnectionDuration(): String? { - if (state != ConnectionState.CONNECTED || connectedSince == null) { - return null - } - - val durationMs = System.currentTimeMillis() - connectedSince - val seconds = durationMs / 1000 - val minutes = seconds / 60 - val hours = minutes / 60 - val days = hours / 24 - - return when { - days > 0 -> "${days}д ${hours % 24}ч" - hours > 0 -> "${hours}ч ${minutes % 60}м" - minutes > 0 -> "${minutes}м ${seconds % 60}с" - else -> "${seconds}с" - } -} - -fun ConnectionStatus.getPingDisplay(context: Context): String? { - return when { - latestPing == null -> null - !latestPing.isSuccessful -> context.getString(R.string.connection_ping_no_response) - else -> "${latestPing.latencyMs}мс" - } -} - -fun ConnectionStatus.getQualityColor(): Int { - return connectionQuality.color -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/IpLocationService.kt b/app/src/main/java/com/example/sshproxy/data/IpLocationService.kt deleted file mode 100644 index fbe6b88..0000000 --- a/app/src/main/java/com/example/sshproxy/data/IpLocationService.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.example.sshproxy.data - -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.URL - -data class IpLocation( - val ip: String, - val country: String, - val countryCode: String, - val flag: String -) - -object IpLocationService { - private const val TAG = "IpLocationService" - private const val CACHE_DURATION_NO_VPN_MS = 10 * 60 * 1000L // 10 minutes cache when no VPN - private const val CACHE_DURATION_VPN_MS = Long.MAX_VALUE // Cache VPN IP for entire session - - private var cachedIpLocation: IpLocation? = null - private var cachedSimpleIp: String? = null - private var lastFetchTime = 0L - private var lastKnownVpnState = false - private var isVpnSession = false - - // Карта кодов стран на флаги эмодзи - private val countryToFlag = mapOf( - "US" to "🇺🇸", "GB" to "🇬🇧", "DE" to "🇩🇪", "FR" to "🇫🇷", "RU" to "🇷🇺", - "CN" to "🇨🇳", "JP" to "🇯🇵", "IN" to "🇮🇳", "BR" to "🇧🇷", "CA" to "🇨🇦", - "AU" to "🇦🇺", "IT" to "🇮🇹", "ES" to "🇪🇸", "MX" to "🇲🇽", "KR" to "🇰🇷", - "NL" to "🇳🇱", "SE" to "🇸🇪", "NO" to "🇳🇴", "CH" to "🇨🇭", "FI" to "🇫🇮", - "DK" to "🇩🇰", "BE" to "🇧🇪", "AT" to "🇦🇹", "IE" to "🇮🇪", "PL" to "🇵🇱", - "CZ" to "🇨🇿", "HU" to "🇭🇺", "GR" to "🇬🇷", "PT" to "🇵🇹", "TR" to "🇹🇷", - "IL" to "🇮🇱", "SA" to "🇸🇦", "AE" to "🇦🇪", "EG" to "🇪🇬", "ZA" to "🇿🇦", - "NG" to "🇳🇬", "KE" to "🇰🇪", "GH" to "🇬🇭", "AR" to "🇦🇷", "CL" to "🇨🇱", - "PE" to "🇵🇪", "CO" to "🇨🇴", "VE" to "🇻🇪", "TH" to "🇹🇭", "VN" to "🇻🇳", - "MY" to "🇲🇾", "SG" to "🇸🇬", "ID" to "🇮🇩", "PH" to "🇵🇭", "BD" to "🇧🇩", - "PK" to "🇵🇰", "LK" to "🇱🇰", "NZ" to "🇳🇿", "UA" to "🇺🇦", "BY" to "🇧🇾", - "RO" to "🇷🇴", "BG" to "🇧🇬", "RS" to "🇷🇸", "HR" to "🇭🇷", "SI" to "🇸🇮", - "SK" to "🇸🇰", "LT" to "🇱🇹", "LV" to "🇱🇻", "EE" to "🇪🇪", "IS" to "🇮🇸" - ) - - suspend fun getIpLocation(): IpLocation? = withContext(Dispatchers.IO) { - // Check cache first with dynamic duration based on VPN state - val now = System.currentTimeMillis() - val cacheDuration = if (isVpnSession) CACHE_DURATION_VPN_MS else CACHE_DURATION_NO_VPN_MS - - if (cachedIpLocation != null && (now - lastFetchTime) < cacheDuration) { - val cacheAge = (now - lastFetchTime) / 1000 // seconds - val sessionType = if (isVpnSession) "VPN session" else "no VPN" - Log.d(TAG, "Returning cached IP location (${cachedIpLocation?.ip}) - $sessionType, age: ${cacheAge}s") - return@withContext cachedIpLocation - } - - // Always get a reliable IPv4 address first - val ipAddress = getReliableIp() ?: return@withContext null - - // Try to get country information - var country = "Unknown location" - var countryCode = "" - var flag = "🌍" - - try { - // Пытаемся получить информацию о стране через ipapi.co - val url = URL("https://ipapi.co/json/") - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connectTimeout = 3000 - connection.readTimeout = 5000 - connection.setRequestProperty("User-Agent", "SSH-Proxy-Android/1.0") - - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val reader = BufferedReader(InputStreamReader(connection.inputStream)) - val response = reader.readText() - reader.close() - - val json = JSONObject(response) - val countryName = json.optString("country_name", "") - val code = json.optString("country_code", "") - - if (countryName.isNotEmpty() && code.isNotEmpty()) { - country = countryName - countryCode = code - flag = countryToFlag[countryCode] ?: "🌍" - Log.d(TAG, "Country info: $country ($countryCode) $flag") - } else { - Log.w(TAG, "No country data from ipapi.co") - } - } else { - Log.w(TAG, "HTTP error from ipapi.co: $responseCode (using fallback country)") - } - } catch (e: Exception) { - Log.w(TAG, "Error fetching country info, using fallback: ${e.message}") - } - - // Create result with reliable IP and best available country info - val ipLocation = IpLocation(ipAddress, country, countryCode, flag) - - // Cache the result - cachedIpLocation = ipLocation - lastFetchTime = now - - Log.d(TAG, "Final IP location: $ipAddress, $country ($countryCode) $flag") - return@withContext ipLocation - } - - private suspend fun getReliableIp(): String? = withContext(Dispatchers.IO) { - // Try multiple services to get a clean IPv4 address - val ipServices = listOf( - "https://httpbin.org/ip" to "origin", - "https://api.ipify.org?format=json" to "ip", - "https://ipinfo.io/ip" to null // returns plain text - ) - - for ((serviceUrl, jsonKey) in ipServices) { - try { - val url = URL(serviceUrl) - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connectTimeout = 3000 - connection.readTimeout = 5000 - connection.setRequestProperty("User-Agent", "SSH-Proxy-Android/1.0") - - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val reader = BufferedReader(InputStreamReader(connection.inputStream)) - val response = reader.readText().trim() - reader.close() - - val ip = if (jsonKey != null) { - try { - JSONObject(response).optString(jsonKey, "") - } catch (e: Exception) { - "" - } - } else { - response // plain text response - } - - if (ip.isNotEmpty() && isValidIPv4(ip)) { - Log.d(TAG, "Got reliable IPv4 from $serviceUrl: $ip") - return@withContext ip - } - } - } catch (e: Exception) { - Log.w(TAG, "Failed to get IP from $serviceUrl: ${e.message}") - } - } - - Log.e(TAG, "Failed to get reliable IP from all services") - return@withContext null - } - - private fun isValidIPv4(ip: String): Boolean { - return try { - val parts = ip.split(".") - parts.size == 4 && parts.all { part -> - val num = part.toIntOrNull() - num != null && num in 0..255 - } - } catch (e: Exception) { - false - } - } - - suspend fun getSimpleIp(): String? = withContext(Dispatchers.IO) { - // This now delegates to the reliable IP method - return@withContext getReliableIp() - } - - /** - * Invalidate cache when VPN state changes (connect/disconnect/reconnect) - * This forces fresh IP detection on the next request - */ - fun invalidateCacheOnVpnChange(isVpnConnected: Boolean) { - android.util.Log.d(TAG, "invalidateCacheOnVpnChange called, isVpnConnected: $isVpnConnected") - if (lastKnownVpnState != isVpnConnected) { - Log.d(TAG, "VPN state changed: $lastKnownVpnState -> $isVpnConnected, invalidating IP cache") - cachedIpLocation = null - cachedSimpleIp = null - lastFetchTime = 0L - lastKnownVpnState = isVpnConnected - isVpnSession = isVpnConnected - - val cacheStrategy = if (isVpnConnected) "session-long caching" else "10-minute caching" - Log.d(TAG, "Switching to $cacheStrategy") - } - } - - /** - * Force refresh IP info regardless of cache (for manual refresh button) - */ - fun forceRefresh() { - Log.d(TAG, "Force refresh requested, invalidating cache") - cachedIpLocation = null - cachedSimpleIp = null - lastFetchTime = 0L - } - - /** - * Get cached IP info without triggering network requests - * Returns null if no valid cache exists - */ - fun getCachedIpLocation(): IpLocation? { - val now = System.currentTimeMillis() - val cacheDuration = if (isVpnSession) CACHE_DURATION_VPN_MS else CACHE_DURATION_NO_VPN_MS - - return if (cachedIpLocation != null && (now - lastFetchTime) < cacheDuration) { - cachedIpLocation - } else { - null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/KeyDao.kt b/app/src/main/java/com/example/sshproxy/data/KeyDao.kt deleted file mode 100644 index 6522807..0000000 --- a/app/src/main/java/com/example/sshproxy/data/KeyDao.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.sshproxy.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import kotlinx.coroutines.flow.Flow - -@Dao -interface KeyDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertKey(key: SshKey) - - @Query("SELECT * FROM ssh_keys") - fun getAllKeys(): Flow> - - @Query("SELECT * FROM ssh_keys WHERE id = :id") - suspend fun getKeyById(id: String): SshKey? - - @Query("DELETE FROM ssh_keys WHERE id = :id") - suspend fun deleteKey(id: String) -} diff --git a/app/src/main/java/com/example/sshproxy/data/KeyRepository.kt b/app/src/main/java/com/example/sshproxy/data/KeyRepository.kt deleted file mode 100644 index e08c48f..0000000 --- a/app/src/main/java/com/example/sshproxy/data/KeyRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext -import java.security.PrivateKey - -class KeyRepository(private val context: Context) { - private val database = ServerDatabase.getDatabase(context) - private val keyDao = database.keyDao() - - fun getAllKeys(): Flow> = keyDao.getAllKeys() - - suspend fun insertKey(key: SshKey) { - withContext(Dispatchers.IO) { - keyDao.insertKey(key) - } - } - - suspend fun getKeyById(id: String): SshKey? { - return withContext(Dispatchers.IO) { - keyDao.getKeyById(id) - } - } - - suspend fun generateKeyPair(name: String) { - withContext(Dispatchers.IO) { - SshKeyManager(context, this@KeyRepository).generateKeyPair(name) - } - } - - suspend fun deleteKey(id: String) { - withContext(Dispatchers.IO) { - keyDao.deleteKey(id) - SshKeyManager(context, this@KeyRepository).deleteKeyFiles(id) - } - } - - /** - * Get decrypted private key for SSH operations - * @param id SSH key identifier - * @return PrivateKey object for SSH connections, null if not found or decryption failed - */ - suspend fun getPrivateKey(id: String): PrivateKey? { - return withContext(Dispatchers.IO) { - SshKeyManager(context, this@KeyRepository).getPrivateKey(id) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/PreferencesManager.kt b/app/src/main/java/com/example/sshproxy/data/PreferencesManager.kt deleted file mode 100644 index 2663dba..0000000 --- a/app/src/main/java/com/example/sshproxy/data/PreferencesManager.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import android.content.SharedPreferences - -class PreferencesManager(context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - - companion object { - private const val KEY_ACTIVE_SERVER_ID = "active_server_id" - private const val KEY_ACTIVE_KEY_ID = "active_key_id" - private const val KEY_AUTO_RECONNECT = "auto_reconnect" - private const val KEY_HEALTH_CHECK_INTERVAL = "health_check_interval" - private const val KEY_MAX_RECONNECT_ATTEMPTS = "max_reconnect_attempts" - private const val KEY_INITIAL_BACKOFF_MS = "initial_backoff_ms" - private const val KEY_MAX_BACKOFF_MS = "max_backoff_ms" - private const val KEY_BACKOFF_MULTIPLIER = "backoff_multiplier" - private const val KEY_THEME = "theme" - private const val KEY_LANGUAGE = "language" - private const val KEY_SPLIT_TUNNELING_APPS = "split_tunneling_apps" - } - - // Active Server ID - fun getActiveServerId(): Long? { - val id = prefs.getLong(KEY_ACTIVE_SERVER_ID, -1) - return if (id == -1L) null else id - } - - fun setActiveServerId(serverId: Long) { - prefs.edit().putLong(KEY_ACTIVE_SERVER_ID, serverId).apply() - } - - // Active Key ID - fun setActiveKeyId(id: String) { - prefs.edit().putString(KEY_ACTIVE_KEY_ID, id).apply() - } - - fun getActiveKeyId(): String? { - return prefs.getString(KEY_ACTIVE_KEY_ID, null) - } - - // Auto-reconnection settings - fun isAutoReconnectEnabled(): Boolean { - return prefs.getBoolean(KEY_AUTO_RECONNECT, true) - } - - fun setAutoReconnectEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply() - } - - fun getHealthCheckInterval(): Long { - return prefs.getLong(KEY_HEALTH_CHECK_INTERVAL, 10_000) - } - - fun setHealthCheckInterval(intervalMs: Long) { - prefs.edit().putLong(KEY_HEALTH_CHECK_INTERVAL, intervalMs).apply() - } - - fun getMaxReconnectAttempts(): Int { - return prefs.getInt(KEY_MAX_RECONNECT_ATTEMPTS, 10) - } - - fun setMaxReconnectAttempts(attempts: Int) { - prefs.edit().putInt(KEY_MAX_RECONNECT_ATTEMPTS, attempts).apply() - } - - fun getInitialBackoffMs(): Long { - return prefs.getLong(KEY_INITIAL_BACKOFF_MS, 1_000) - } - - fun setInitialBackoffMs(backoffMs: Long) { - prefs.edit().putLong(KEY_INITIAL_BACKOFF_MS, backoffMs).apply() - } - - fun getMaxBackoffMs(): Long { - return prefs.getLong(KEY_MAX_BACKOFF_MS, 300_000) - } - - fun setMaxBackoffMs(backoffMs: Long) { - prefs.edit().putLong(KEY_MAX_BACKOFF_MS, backoffMs).apply() - } - - fun getBackoffMultiplier(): Float { - return prefs.getFloat(KEY_BACKOFF_MULTIPLIER, 2.0f) - } - - fun setBackoffMultiplier(multiplier: Float) { - prefs.edit().putFloat(KEY_BACKOFF_MULTIPLIER, multiplier).apply() - } - - // Theme - fun getTheme(): String { - return prefs.getString(KEY_THEME, "system") ?: "system" - } - - fun setTheme(theme: String) { - prefs.edit().putString(KEY_THEME, theme).apply() - } - - // Language - fun getLanguage(): String { - return prefs.getString(KEY_LANGUAGE, "system") ?: "system" - } - - fun setLanguage(language: String) { - prefs.edit().putString(KEY_LANGUAGE, language).apply() - } - - // Split Tunneling — global allowlist of app package names - fun getSplitTunnelingApps(): Set = - prefs.getStringSet(KEY_SPLIT_TUNNELING_APPS, emptySet()) ?: emptySet() - - fun setSplitTunnelingApps(packages: Set) = - prefs.edit().putStringSet(KEY_SPLIT_TUNNELING_APPS, packages).apply() -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/Server.kt b/app/src/main/java/com/example/sshproxy/data/Server.kt deleted file mode 100644 index 6a8af1a..0000000 --- a/app/src/main/java/com/example/sshproxy/data/Server.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.example.sshproxy.data - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "servers") -data class Server( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "host") - val host: String, - - @ColumnInfo(name = "port") - val port: Int = 22, - - @ColumnInfo(name = "username") - val username: String = "user", - - @ColumnInfo(name = "http_proxy_port") - val httpProxyPort: Int = 8080, // HTTP proxy port - - @ColumnInfo(name = "ssh_key_id") - val sshKeyId: String? = null, // ID SSH ключа для этого сервера - - @ColumnInfo(name = "is_favorite") - val isFavorite: Boolean = false, - - @ColumnInfo(name = "created_at") - val createdAt: Long = System.currentTimeMillis(), - - @ColumnInfo(name = "last_used") - val lastUsed: Long? = null, - - @ColumnInfo(name = "preferred_cipher") - val preferredCipher: String? = null, - - @ColumnInfo(name = "preferred_kex") - val preferredKex: String? = null, - - @ColumnInfo(name = "preferred_mac") - val preferredMac: String? = null -) diff --git a/app/src/main/java/com/example/sshproxy/data/ServerDao.kt b/app/src/main/java/com/example/sshproxy/data/ServerDao.kt deleted file mode 100644 index ff3e992..0000000 --- a/app/src/main/java/com/example/sshproxy/data/ServerDao.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.sshproxy.data - -import androidx.room.* -import kotlinx.coroutines.flow.Flow - -@Dao -interface ServerDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertServer(server: Server) - - @Delete - suspend fun deleteServer(server: Server) - - @Update - suspend fun updateServer(server: Server) - - @Query("SELECT * FROM servers") - fun getAllServers(): Flow> - - @Query("SELECT * FROM servers WHERE id = :id") - suspend fun getServerById(id: Long): Server? - - @Query("SELECT * FROM servers WHERE is_favorite = 1") - fun getFavoriteServers(): Flow> -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/ServerDatabase.kt b/app/src/main/java/com/example/sshproxy/data/ServerDatabase.kt deleted file mode 100644 index fc0ca4a..0000000 --- a/app/src/main/java/com/example/sshproxy/data/ServerDatabase.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverter -import androidx.room.TypeConverters -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Converters { - @TypeConverter - fun fromKeyType(value: SshKeyManager.KeyType): String { - return value.name - } - - @TypeConverter - fun toKeyType(value: String): SshKeyManager.KeyType { - return SshKeyManager.KeyType.valueOf(value) - } -} - -@Database( - entities = [Server::class, SshKey::class], - version = 9, // Incremented version for preferred algorithms - exportSchema = false -) -@TypeConverters(Converters::class) -abstract class ServerDatabase : RoomDatabase() { - abstract fun serverDao(): ServerDao - abstract fun keyDao(): KeyDao - - companion object { - @Volatile - private var INSTANCE: ServerDatabase? = null - - private val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE servers ADD COLUMN ssh_key_id TEXT DEFAULT NULL") - } - } - - private val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE ssh_keys ADD COLUMN keyType TEXT NOT NULL DEFAULT 'ED25519'") - } - } - - private val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE servers ADD COLUMN http_proxy_port INTEGER NOT NULL DEFAULT 8080") - } - } - - private val MIGRATION_6_7 = object : Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE servers ADD COLUMN http_proxy_auth_enabled INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE servers ADD COLUMN http_proxy_username TEXT DEFAULT NULL") - db.execSQL("ALTER TABLE servers ADD COLUMN http_proxy_password TEXT DEFAULT NULL") - } - } - - private val MIGRATION_7_8 = object : Migration(7, 8) { - override fun migrate(db: SupportSQLiteDatabase) { - // Create a new table with the desired schema - db.execSQL(""" - CREATE TABLE servers_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - name TEXT NOT NULL, - host TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - http_proxy_port INTEGER NOT NULL, - ssh_key_id TEXT, - is_favorite INTEGER NOT NULL, - created_at INTEGER NOT NULL, - last_used INTEGER - ) - """) - - // Copy the data from the old table to the new table - db.execSQL(""" - INSERT INTO servers_new (id, name, host, port, username, http_proxy_port, ssh_key_id, is_favorite, created_at, last_used) - SELECT id, name, host, port, username, http_proxy_port, ssh_key_id, is_favorite, created_at, last_used FROM servers - """) - - // Remove the old table - db.execSQL("DROP TABLE servers") - - // Rename the new table to the original table name - db.execSQL("ALTER TABLE servers_new RENAME TO servers") - } - } - - private val MIGRATION_8_9 = object : Migration(8, 9) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE servers ADD COLUMN preferred_cipher TEXT DEFAULT NULL") - db.execSQL("ALTER TABLE servers ADD COLUMN preferred_kex TEXT DEFAULT NULL") - db.execSQL("ALTER TABLE servers ADD COLUMN preferred_mac TEXT DEFAULT NULL") - } - } - - fun getDatabase(context: Context): ServerDatabase { - return INSTANCE ?: synchronized(this) { - val instance = Room.databaseBuilder( - context.applicationContext, - ServerDatabase::class.java, - "server_database" - ) - .addMigrations(MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) - .build() - INSTANCE = instance - instance - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/ServerRepository.kt b/app/src/main/java/com/example/sshproxy/data/ServerRepository.kt deleted file mode 100644 index e48b937..0000000 --- a/app/src/main/java/com/example/sshproxy/data/ServerRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import com.example.sshproxy.security.SecureHostKeyVerifier -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext - -class ServerRepository(private val context: Context) { - private val database = ServerDatabase.getDatabase(context) - private val serverDao = database.serverDao() - private val hostKeyVerifier by lazy { SecureHostKeyVerifier(context) } - - fun getAllServers(): Flow> = serverDao.getAllServers() - - fun getFavoriteServers(): Flow> = serverDao.getFavoriteServers() - - suspend fun insertServer(server: Server) { - withContext(Dispatchers.IO) { - serverDao.insertServer(server) - } - } - - suspend fun deleteServer(server: Server) { - withContext(Dispatchers.IO) { - serverDao.deleteServer(server) - } - } - - suspend fun updateServer(server: Server) { - withContext(Dispatchers.IO) { - serverDao.updateServer(server) - } - } - - suspend fun getServerById(id: Long): Server? { - return withContext(Dispatchers.IO) { - serverDao.getServerById(id) - } - } - - /** - * Get stored fingerprint for a server - * @param server The server to get fingerprint for - * @return Server fingerprint or null if not stored - */ - fun getServerFingerprint(server: Server): String? { - return hostKeyVerifier.getServerFingerprint(server.host, server.port) - } -} diff --git a/app/src/main/java/com/example/sshproxy/data/SshKey.kt b/app/src/main/java/com/example/sshproxy/data/SshKey.kt deleted file mode 100644 index b1538c8..0000000 --- a/app/src/main/java/com/example/sshproxy/data/SshKey.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.sshproxy.data - -import androidx.room.Entity -import androidx.room.PrimaryKey - -import com.example.sshproxy.data.SshKeyManager - -@Entity(tableName = "ssh_keys") -data class SshKey( - @PrimaryKey - val id: String, - val name: String, - val publicKey: String, - val fingerprint: String, - val keyType: SshKeyManager.KeyType, // Added keyType - val createdAt: Long = System.currentTimeMillis() -) diff --git a/app/src/main/java/com/example/sshproxy/data/SshKeyManager.kt b/app/src/main/java/com/example/sshproxy/data/SshKeyManager.kt deleted file mode 100644 index 81f014f..0000000 --- a/app/src/main/java/com/example/sshproxy/data/SshKeyManager.kt +++ /dev/null @@ -1,390 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import android.util.Base64 -import android.util.Log -import com.example.sshproxy.security.KeystoreManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withContext -import org.bouncycastle.jcajce.provider.asymmetric.edec.BCEdDSAPublicKey -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.bouncycastle.util.io.pem.PemObject -import org.bouncycastle.util.io.pem.PemWriter -import java.io.ByteArrayOutputStream -import java.io.DataOutputStream -import java.io.File -import java.io.StringWriter -import java.math.BigInteger -import java.security.* -import java.security.interfaces.ECPublicKey -import java.security.interfaces.RSAPublicKey -import java.security.spec.AlgorithmParameterSpec -import java.security.spec.ECGenParameterSpec -import java.security.spec.PKCS8EncodedKeySpec -import java.security.spec.X509EncodedKeySpec -import java.util.* - - -class SshKeyManager(private val context: Context, private val keyRepository: KeyRepository) { - // Сохраняет зашифрованный PEM-файл приватного ключа - fun saveEncryptedPem(keyId: String, pem: String) { - val password = com.example.sshproxy.security.KeyPasswordKeystore.getOrCreatePassword(context, keyId) - val encryptedPem = com.example.sshproxy.security.PemAesUtil.encryptPem(pem, password) - com.example.sshproxy.security.KeyPasswordStorage.storeEncryptedPem(context, keyId, encryptedPem) - } - - // Загружает и расшифровывает PEM-файл приватного ключа - fun loadDecryptedPem(keyId: String): String? { - Log.d(TAG, "loadDecryptedPem called for keyId: $keyId") - val encryptedPem = com.example.sshproxy.security.KeyPasswordStorage.getEncryptedPem(context, keyId) - Log.d(TAG, "Encrypted PEM from storage: ${if (encryptedPem != null) "Found (${encryptedPem.length} chars)" else "NULL"}") - - val password = com.example.sshproxy.security.KeyPasswordKeystore.getOrCreatePassword(context, keyId) - Log.d(TAG, "Password from Keystore: ${if (password != null) "Found" else "NULL"}") - - return if (encryptedPem != null && password != null) { - try { - val decrypted = com.example.sshproxy.security.PemAesUtil.decryptPem(encryptedPem, password) - Log.d(TAG, "PEM decryption successful: ${if (decrypted != null) decrypted.length else 0} chars") - decrypted - } catch (e: Exception) { - Log.e(TAG, "PEM decryption failed", e) - null - } - } else { - Log.e(TAG, "Cannot decrypt: encryptedPem=${encryptedPem != null}, password=${password != null}") - null - } - } - - enum class KeyType(val algorithm: String, val spec: Any?, val sshName: String) { - RSA("RSA", 4096, "ssh-rsa"), - ED25519("Ed25519", null, "ssh-ed25519"), - ECDSA_256("EC", ECGenParameterSpec("secp256r1"), "ecdsa-sha2-nistp256") - } - - companion object { - private const val TAG = "SshKeyManager" - private const val PRIVATE_KEY_PREFIX = "ssh_private_encrypted_" - private const val IV_PREFIX = "ssh_iv_" - private const val PUBLIC_KEY_PREFIX = "ssh_public_" - val DEFAULT_KEY_TYPE = KeyType.ECDSA_256 - - init { - Security.removeProvider("BC") - Security.insertProviderAt(BouncyCastleProvider(), 1) - } - } - - private val keystoreManager = KeystoreManager() - - suspend fun hasKeyPair(): Boolean = withContext(Dispatchers.IO) { - keyRepository.getAllKeys().first().isNotEmpty() - } - - - suspend fun generateKeyPair(name: String, keyType: KeyType = DEFAULT_KEY_TYPE): SshKey = withContext(Dispatchers.IO) { - try { - val keyId = UUID.randomUUID().toString() - val keyGen = KeyPairGenerator.getInstance(keyType.algorithm) - when (val spec = keyType.spec) { - is Int -> keyGen.initialize(spec) - is AlgorithmParameterSpec -> keyGen.initialize(spec) - } - val keyPair = keyGen.generateKeyPair() - - // --- Новый способ: шифруем PEM паролем из Keystore, сохраняем IV+encrypted в SharedPreferences --- - val privateKeyPem = convertPrivateKeyToPem(keyPair.private) - val password = com.example.sshproxy.security.KeyPasswordKeystore.getOrCreatePassword(context, keyId) - val encryptedPem = com.example.sshproxy.security.PemAesUtil.encryptPem(privateKeyPem, password) - com.example.sshproxy.security.KeyPasswordStorage.storeEncryptedPem(context, keyId, encryptedPem) - - // Сохраняем публичный ключ (старый способ) - val publicKeyFile = File(context.filesDir, "$PUBLIC_KEY_PREFIX$keyId") - publicKeyFile.writeBytes(keyPair.public.encoded) - - val publicKeyString = formatSshPublicKey(keyPair.public, keyType) - val fingerprint = generateFingerprint(keyPair.public) - - val newKey = SshKey( - id = keyId, - name = name, - publicKey = publicKeyString, - fingerprint = fingerprint, - keyType = keyType - ) - keyRepository.insertKey(newKey) - newKey - } catch (e: Exception) { - Log.e(TAG, "Failed to generate key pair", e) - throw e - } - } - - private fun saveKeyPair(keyPair: KeyPair, keyId: String) { - // Save private key encrypted using Android Keystore - val privateKeyPem = convertPrivateKeyToPem(keyPair.private) - val encryptedData = keystoreManager.encryptPrivateKey(keyId, privateKeyPem.toByteArray()) - - val privateKeyFile = File(context.filesDir, "$PRIVATE_KEY_PREFIX$keyId") - privateKeyFile.writeBytes(encryptedData.ciphertext) - - val ivFile = File(context.filesDir, "$IV_PREFIX$keyId") - ivFile.writeBytes(encryptedData.iv) - - // Save public key unencrypted (it's public anyway) - val publicKeyFile = File(context.filesDir, "$PUBLIC_KEY_PREFIX$keyId") - publicKeyFile.writeBytes(keyPair.public.encoded) - } - - fun convertPrivateKeyToPem(privateKey: PrivateKey): String { - val stringWriter = StringWriter() - PemWriter(stringWriter).use { pemWriter -> - pemWriter.writeObject(PemObject("PRIVATE KEY", privateKey.encoded)) - } - return stringWriter.toString() - } - - suspend fun getPublicKey(keyId: String): String? = withContext(Dispatchers.IO) { - try { - val key = keyRepository.getKeyById(keyId) - if (key == null) { - Log.e(TAG, "Key with id $keyId not found in database") - return@withContext null - } - - val publicKeyFile = File(context.filesDir, "$PUBLIC_KEY_PREFIX$keyId") - if (!publicKeyFile.exists()) { - Log.e(TAG, "Public key file for keyId $keyId not found") - return@withContext null - } - - val keyBytes = publicKeyFile.readBytes() - if (keyBytes.isEmpty()) { - Log.e(TAG, "Public key file for keyId $keyId is empty") - return@withContext null - } - val keySpec = X509EncodedKeySpec(keyBytes) - val keyFactory = KeyFactory.getInstance(key.keyType.algorithm) - val publicKey = try { keyFactory.generatePublic(keySpec) } catch (e: Exception) { - Log.e(TAG, "Failed to generate public key from spec", e) - return@withContext null - } - formatSshPublicKey(publicKey, key.keyType) - } catch (e: Exception) { - Log.e(TAG, "Failed to load public key", e) - null - } - } - - suspend fun getActivePublicKey(): String = withContext(Dispatchers.IO) { - try { - val preferencesManager = PreferencesManager(context) - val activeKeyId = preferencesManager.getActiveKeyId() - - if (activeKeyId != null) { - getPublicKey(activeKeyId) ?: "" - } else { - // If no active key set, try to get the first available key - val keys = keyRepository.getAllKeys().first() - if (keys.isNotEmpty()) { - val firstKey = keys.first() - preferencesManager.setActiveKeyId(firstKey.id) - firstKey.publicKey - } else { - "" - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to get active public key", e) - "" - } - } - - private fun formatSshPublicKey(publicKey: PublicKey, keyType: KeyType): String { - if (publicKey == null) { - Log.e(TAG, "formatSshPublicKey: publicKey is null") - return "Error: publicKey is null" - } - val out = ByteArrayOutputStream() - val dos = DataOutputStream(out) - try { - val sshNameBytes = keyType.sshName.toByteArray() - dos.writeInt(sshNameBytes.size) - dos.write(sshNameBytes) - - when (keyType) { - KeyType.RSA -> { - val rsaPublicKey = publicKey as? RSAPublicKey - if (rsaPublicKey == null) return "Error: Not an RSA public key" - val e = rsaPublicKey.publicExponent?.toByteArray() ?: return "Error: RSA exponent is null" - val m = rsaPublicKey.modulus?.toByteArray() ?: return "Error: RSA modulus is null" - dos.writeInt(e.size) - dos.write(e) - dos.writeInt(m.size) - dos.write(m) - } - KeyType.ED25519 -> { - val edPublicKey = publicKey as? BCEdDSAPublicKey - if (edPublicKey == null) return "Error: Not an ED25519 public key" - val p = edPublicKey.pointEncoding?.reversedArray() ?: return "Error: ED25519 pointEncoding is null" - dos.writeInt(p.size) - dos.write(p) - } - KeyType.ECDSA_256 -> { - val ecPublicKey = publicKey as? ECPublicKey - if (ecPublicKey == null) return "Error: Not an ECDSA public key" - val curveName = "nistp256" - val curveNameBytes = curveName.toByteArray() - dos.writeInt(curveNameBytes.size) - dos.write(curveNameBytes) - val x = ecPublicKey.w.affineX?.toByteArray(32) ?: return "Error: ECDSA X is null" - val y = ecPublicKey.w.affineY?.toByteArray(32) ?: return "Error: ECDSA Y is null" - val p = x + y - val uncompressed = ByteArray(1) { 4 } + p - dos.writeInt(uncompressed.size) - dos.write(uncompressed) - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to format public key for keyType: $keyType", e) - return "Error formatting key: ${e.message}" - } - return "${keyType.sshName} " + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) - } - - private fun generateFingerprint(publicKey: PublicKey): String { - return try { - val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest(buildSshPublicKey(publicKey)) - "SHA256:" + Base64.encodeToString(digest, Base64.NO_WRAP or Base64.NO_PADDING).removeSuffix("=") - } catch (e: Exception) { - "Error generating fingerprint" - } - } - - fun getPrivateKeyFile(keyId: String): File { - return File(context.filesDir, "$PRIVATE_KEY_PREFIX$keyId") - } - - /** - * Get decrypted private key for SSH operations - * @param keyId SSH key identifier - * @return PrivateKey object for SSH connections - */ - suspend fun getPrivateKey(keyId: String): PrivateKey? = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "getPrivateKey called for keyId: $keyId") - val key = keyRepository.getKeyById(keyId) - if (key == null) { - Log.e(TAG, "Key not found in database for keyId: $keyId") - return@withContext null - } - Log.d(TAG, "Key found in database: ${key.name}, type: ${key.keyType}") - - // 1. Пробуем зашифрованный PEM-файл с паролем из Keystore - val pemString = loadDecryptedPem(keyId) - Log.d(TAG, "loadDecryptedPem returned: ${if (pemString != null) "PEM content (${pemString.length} chars)" else "null"}") - - if (pemString != null) { - val privateKey = parsePemToPrivateKey(pemString, key.keyType) - Log.d(TAG, "parsePemToPrivateKey returned: ${if (privateKey != null) "PrivateKey object" else "null"}") - return@withContext privateKey - } - Log.e(TAG, "No private key found for keyId: $keyId") - return@withContext null - } catch (e: Exception) { - Log.e(TAG, "Failed to get private key for keyId: $keyId", e) - null - } - } - - private fun parsePemToPrivateKey(pemString: String, keyType: KeyType): PrivateKey? { - try { - Log.d(TAG, "parsePemToPrivateKey: keyType=${keyType.algorithm}, pemLength=${pemString.length}") - - // Extract base64 content from PEM - val base64Content = pemString - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("\n", "") - .replace("\r", "") - - Log.d(TAG, "Base64 content length: ${base64Content.length}") - - val keyBytes = Base64.decode(base64Content, Base64.DEFAULT) - Log.d(TAG, "Decoded key bytes: ${keyBytes.size} bytes") - - val keySpec = PKCS8EncodedKeySpec(keyBytes) - val keyFactory = KeyFactory.getInstance(keyType.algorithm) - val privateKey = keyFactory.generatePrivate(keySpec) - - Log.d(TAG, "Successfully created PrivateKey: ${privateKey.algorithm}") - return privateKey - } catch (e: Exception) { - Log.e(TAG, "Failed to parse PEM to private key for keyType: ${keyType.algorithm}", e) - return null - } - } - - fun deleteKeyFiles(keyId: String) { - File(context.filesDir, "$PRIVATE_KEY_PREFIX$keyId").delete() - File(context.filesDir, "$IV_PREFIX$keyId").delete() - File(context.filesDir, "$PUBLIC_KEY_PREFIX$keyId").delete() - // Also delete the encryption key from Android Keystore - keystoreManager.deleteEncryptionKey(keyId) - } - - private fun buildSshPublicKey(key: PublicKey): ByteArray { - val byteos = ByteArrayOutputStream() - val dos = DataOutputStream(byteos) - - when (key) { - is RSAPublicKey -> { - val sshName = "ssh-rsa".toByteArray() - dos.writeInt(sshName.size) - dos.write(sshName) - dos.writeInt(key.publicExponent.toByteArray().size) - dos.write(key.publicExponent.toByteArray()) - dos.writeInt(key.modulus.toByteArray().size) - dos.write(key.modulus.toByteArray()) - } - is ECPublicKey -> { - val sshName = "ecdsa-sha2-nistp256".toByteArray() - dos.writeInt(sshName.size) - dos.write(sshName) - val curveName = "nistp256".toByteArray() - dos.writeInt(curveName.size) - dos.write(curveName) - val p = key.w.affineX.toByteArray(32) + key.w.affineY.toByteArray(32) - val uncompressed = ByteArray(1) { 4 } + p - dos.writeInt(uncompressed.size) - dos.write(uncompressed) - } - is BCEdDSAPublicKey -> { - val sshName = "ssh-ed25519".toByteArray() - dos.writeInt(sshName.size) - dos.write(sshName) - val p = key.pointEncoding.reversedArray() - dos.writeInt(p.size) - dos.write(p) - } - } - return byteos.toByteArray() - } - - // Helper to pad byte array to a specific size - private fun BigInteger.toByteArray(size: Int): ByteArray { - val bytes = toByteArray() - if (bytes.size == size) return bytes - val padded = ByteArray(size) - val offset = if (bytes.size == size + 1 && bytes[0].toInt() == 0) 1 else 0 - val length = bytes.size - offset - if (length > size) { - throw IllegalStateException("BigInteger is too large to fit in $size bytes") - } - System.arraycopy(bytes, offset, padded, size - length, length) - return padded - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/network/HttpLatencyTester.kt b/app/src/main/java/com/example/sshproxy/network/HttpLatencyTester.kt deleted file mode 100644 index f1ad546..0000000 --- a/app/src/main/java/com/example/sshproxy/network/HttpLatencyTester.kt +++ /dev/null @@ -1,256 +0,0 @@ -package com.example.sshproxy.network - -import android.util.Log -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.net.HttpURLConnection -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.URL -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSession -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager -import java.security.cert.X509Certificate -import java.util.Base64 -import kotlin.system.measureTimeMillis - -data class HttpLatencyResult( - val url: String, - val latencyMs: Long, - val isSuccessful: Boolean, - val httpStatusCode: Int = 0, - val errorMessage: String? = null, - val timestamp: Long = System.currentTimeMillis() -) - -data class AggregatedLatencyResult( - val averageLatencyMs: Long, - val medianLatencyMs: Long, - val minLatencyMs: Long, - val maxLatencyMs: Long, - val successfulTests: Int, - val totalTests: Int, - val successRate: Float, - val individualResults: List, - val timestamp: Long = System.currentTimeMillis() -) - -class HttpLatencyTester( - private val proxyHost: String? = "127.0.0.1", - private val proxyPort: Int? = 8080, - private val timeoutMs: Int = 10000 // 10 seconds -) { - companion object { - private const val TAG = "HttpLatencyTester" - - // Test endpoints for latency measurement - private val TEST_ENDPOINTS = listOf( - "https://tazhate.com", - "https://httpbin.org/status/200", - "https://www.google.com/generate_204", // Google connectivity check - "https://detectportal.firefox.com/success.txt", // Firefox connectivity check - "https://connectivitycheck.gstatic.com/generate_204", // Google static connectivity check - "https://clients3.google.com/generate_204", // Alternative Google check - "https://www.msftconnecttest.com/connecttest.txt" // Microsoft connectivity check - ) - } - - private var testingJob: Job? = null - private var isRunning = false - - private val _latestResult = MutableStateFlow(null) - val latestResult: StateFlow = _latestResult.asStateFlow() - - // SSL endpoints that might have certificate issues - private val problematicSslEndpoints = setOf( - "https://www.msftconnecttest.com/connecttest.txt" - ) - - private val _isActive = MutableStateFlow(false) - val isActive: StateFlow = _isActive.asStateFlow() - - fun startContinuousTesting(intervalMs: Long = 10000) { - if (isRunning) { - Log.d(TAG, "HTTP latency testing already running") - return - } - - Log.d(TAG, "Starting continuous HTTP latency testing") - isRunning = true - _isActive.value = true - - testingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { - while (isRunning && currentCoroutineContext().isActive) { - try { - val result = performLatencyTest() - _latestResult.value = result - - delay(intervalMs) - } catch (e: Exception) { - Log.e(TAG, "Error in continuous latency testing", e) - delay(intervalMs) // Still delay to prevent tight loop - } - } - } - } - - fun stopTesting() { - Log.d(TAG, "Stopping HTTP latency testing") - isRunning = false - _isActive.value = false - testingJob?.cancel() - testingJob = null - } - - suspend fun performSingleTest(): AggregatedLatencyResult { - return performLatencyTest() - } - - private suspend fun performLatencyTest(): AggregatedLatencyResult = withContext(Dispatchers.IO) { - val results = mutableListOf() - - // Test all endpoints in parallel for faster results - val jobs = TEST_ENDPOINTS.map { endpoint -> - async { - testSingleEndpoint(endpoint) - } - } - - // Wait for all tests to complete - jobs.awaitAll().forEach { result -> - results.add(result) - } - - // Calculate aggregated statistics - val successfulResults = results.filter { it.isSuccessful } - val latencies = successfulResults.map { it.latencyMs } - - val aggregatedResult = if (latencies.isNotEmpty()) { - val sortedLatencies = latencies.sorted() - AggregatedLatencyResult( - averageLatencyMs = latencies.average().toLong(), - medianLatencyMs = if (sortedLatencies.size % 2 == 0) { - (sortedLatencies[sortedLatencies.size / 2 - 1] + sortedLatencies[sortedLatencies.size / 2]) / 2 - } else { - sortedLatencies[sortedLatencies.size / 2] - }, - minLatencyMs = latencies.minOrNull() ?: 0, - maxLatencyMs = latencies.maxOrNull() ?: 0, - successfulTests = successfulResults.size, - totalTests = results.size, - successRate = (successfulResults.size.toFloat() / results.size) * 100f, - individualResults = results - ) - } else { - // All tests failed - AggregatedLatencyResult( - averageLatencyMs = 0, - medianLatencyMs = 0, - minLatencyMs = 0, - maxLatencyMs = 0, - successfulTests = 0, - totalTests = results.size, - successRate = 0f, - individualResults = results - ) - } - - Log.d(TAG, "HTTP latency test completed: ${aggregatedResult.successfulTests}/${aggregatedResult.totalTests} successful, avg: ${aggregatedResult.averageLatencyMs}ms") - - aggregatedResult - } - - private suspend fun testSingleEndpoint(endpoint: String): HttpLatencyResult = withContext(Dispatchers.IO) { - try { - val url = URL(endpoint) - val connection: HttpURLConnection - if (proxyHost != null && proxyPort != null) { - val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(proxyHost, proxyPort)) - connection = url.openConnection(proxy) as HttpURLConnection - } else { - connection = url.openConnection() as HttpURLConnection - } - - val latency = measureTimeMillis { - - // Handle SSL bypass for problematic endpoints - if (connection is HttpsURLConnection && endpoint in problematicSslEndpoints) { - val httpsConnection = connection as HttpsURLConnection - httpsConnection.sslSocketFactory = createTrustAllSSLContext().socketFactory - httpsConnection.hostnameVerifier = HostnameVerifier { _: String, _: SSLSession -> true } - } - - connection!!.apply { - requestMethod = "GET" - connectTimeout = timeoutMs - readTimeout = timeoutMs - setRequestProperty("User-Agent", "SSH-Proxy-Latency-Test/1.0") - setRequestProperty("Accept", "*/*") - setRequestProperty("Connection", "close") - } - - val responseCode = connection!!.responseCode - - // For some endpoints, we need to read the response to complete the request - if (responseCode == 200) { - connection!!.inputStream.use { it.read() } - } - } - - val responseCode = connection!!.responseCode - - Log.d(TAG, "HTTP test to $endpoint: ${latency}ms, status: $responseCode") - - HttpLatencyResult( - url = endpoint, - latencyMs = latency, - isSuccessful = responseCode in 200..299, - httpStatusCode = responseCode - ) - - } catch (e: Exception) { - val errorMsg = when { - e.message?.contains("timeout") == true -> "Connection timeout" - e.message?.contains("refused") == true -> "Connection refused" - e.message?.contains("unreachable") == true -> "Host unreachable" - e.message?.contains("proxy") == true -> "Proxy error" - else -> e.message ?: "HTTP request failed" - } - - Log.d(TAG, "HTTP test failed for $endpoint: $errorMsg") - - HttpLatencyResult( - url = endpoint, - latencyMs = 0, - isSuccessful = false, - errorMessage = errorMsg - ) - - } finally { - // connection?.disconnect() - } - } - - private fun createTrustAllSSLContext(): SSLContext { - val trustAllCerts = arrayOf( - object : X509TrustManager { - override fun checkClientTrusted(chain: Array, authType: String) {} - override fun checkServerTrusted(chain: Array, authType: String) {} - override fun getAcceptedIssuers(): Array = arrayOf() - } - ) - - val sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, trustAllCerts, java.security.SecureRandom()) - return sslContext - } - - fun reset() { - _latestResult.value = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/network/PingMonitor.kt b/app/src/main/java/com/example/sshproxy/network/PingMonitor.kt deleted file mode 100644 index c9ce2dc..0000000 --- a/app/src/main/java/com/example/sshproxy/network/PingMonitor.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.example.sshproxy.network - -import android.content.Context -import android.util.Log -import com.example.sshproxy.R -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.io.IOException -import java.net.InetSocketAddress -import java.net.Socket -import kotlin.system.measureTimeMillis - -data class PingResult( - val latencyMs: Long, - val isSuccessful: Boolean, - val errorMessage: String? = null, - val timestamp: Long = System.currentTimeMillis() -) - -data class ServerStats( - val averageLatencyMs: Long = 0, - val minLatencyMs: Long = Long.MAX_VALUE, - val maxLatencyMs: Long = 0, - val packetLossPercentage: Float = 0f, - val lastSuccessfulPing: Long = 0, - val consecutiveFailures: Int = 0 -) - -class PingMonitor( - private val hostname: String, - private val port: Int, - private val intervalMs: Long = 5000, // 5 seconds - more frequent pings - private val timeoutMs: Int = 3000, // 3 seconds - faster timeout - private val onConnectionIssue: ((consecutiveFailures: Int) -> Unit)? = null -) { - companion object { - private const val TAG = "PingMonitor" - private const val PING_HISTORY_SIZE = 10 - } - - private var monitoringJob: Job? = null - private var isMonitoring = false - private var httpLatencyTester: HttpLatencyTester? = null - - private val _latestPing = MutableStateFlow(null) - val latestPing: StateFlow = _latestPing.asStateFlow() - - private val _serverStats = MutableStateFlow(ServerStats()) - val serverStats: StateFlow = _serverStats.asStateFlow() - - private val _isActive = MutableStateFlow(false) - val isActive: StateFlow = _isActive.asStateFlow() - - private val pingHistory = mutableListOf() - - fun startMonitoring() { - if (isMonitoring) { - Log.d(TAG, "Already monitoring $hostname:$port") - return - } - - Log.d(TAG, "Starting HTTP latency monitoring via proxy") - isMonitoring = true - _isActive.value = true - - // Initialize HTTP latency tester - httpLatencyTester = HttpLatencyTester( - proxyHost = "127.0.0.1", - timeoutMs = timeoutMs - ) - - monitoringJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { - while (isMonitoring && currentCoroutineContext().isActive) { - try { - // Perform both TCP ping (for quick connection check) and HTTP latency test - val tcpPingResult = performTcpPing() - - // If TCP ping fails, don't bother with HTTP test - val finalResult = if (!tcpPingResult.isSuccessful) { - tcpPingResult - } else { - // Perform HTTP latency test for real-world performance - val httpResult = httpLatencyTester?.performSingleTest() - if (httpResult != null && httpResult.successfulTests > 0) { - // Use HTTP latency as the primary metric - PingResult( - latencyMs = httpResult.averageLatencyMs, - isSuccessful = httpResult.successRate >= 50f, // At least 50% success rate - errorMessage = if (httpResult.successRate < 50f) "Poor HTTP connectivity: ${httpResult.successfulTests}/${httpResult.totalTests} successful" else null - ) - } else { - // Fallback to TCP ping if HTTP test fails completely - tcpPingResult - } - } - - _latestPing.value = finalResult - updateStats(finalResult) - - delay(intervalMs) - } catch (e: Exception) { - Log.e(TAG, "Error in latency monitoring", e) - delay(intervalMs) // Still delay to prevent tight loop - } - } - } - } - - fun stopMonitoring() { - Log.d(TAG, "Stopping HTTP latency monitoring") - isMonitoring = false - _isActive.value = false - monitoringJob?.cancel() - monitoringJob = null - httpLatencyTester?.stopTesting() - httpLatencyTester = null - } - - suspend fun performSinglePing(): PingResult { - return performTcpPing() - } - - private suspend fun performTcpPing(): PingResult = withContext(Dispatchers.IO) { - var socket: Socket? = null - - try { - socket = Socket() - - val latency = measureTimeMillis { - socket.connect(InetSocketAddress(hostname, port), timeoutMs) - } - - Log.d(TAG, "Ping to $hostname:$port: ${latency}ms") - PingResult(latency, true) - - } catch (e: IOException) { - val errorMsg = when { - e.message?.contains("timeout") == true -> "Connection timeout" - e.message?.contains("refused") == true -> "Connection refused" - e.message?.contains("unreachable") == true -> "Host unreachable" - else -> e.message ?: "Connection failed" - } - - Log.d(TAG, "Ping failed to $hostname:$port: $errorMsg") - PingResult(0, false, errorMsg) - - } catch (e: Exception) { - Log.e(TAG, "Unexpected error during ping", e) - PingResult(0, false, e.message ?: "Unknown error") - - } finally { - socket?.close() - } - } - - private fun updateStats(pingResult: PingResult) { - synchronized(pingHistory) { - // Add to history - pingHistory.add(pingResult) - if (pingHistory.size > PING_HISTORY_SIZE) { - pingHistory.removeAt(0) - } - - // Calculate stats - val successfulPings = pingHistory.filter { it.isSuccessful } - val latencies = successfulPings.map { it.latencyMs } - - val currentStats = _serverStats.value - val newStats = if (latencies.isNotEmpty()) { - val avgLatency = latencies.average().toLong() - val minLatency = latencies.minOrNull() ?: 0 - val maxLatency = latencies.maxOrNull() ?: 0 - val packetLoss = ((pingHistory.size - successfulPings.size).toFloat() / pingHistory.size) * 100f - val lastSuccess = if (pingResult.isSuccessful) pingResult.timestamp else currentStats.lastSuccessfulPing - val consecutiveFailures = if (pingResult.isSuccessful) 0 else currentStats.consecutiveFailures + 1 - - ServerStats( - averageLatencyMs = avgLatency, - minLatencyMs = minLatency, - maxLatencyMs = maxLatency, - packetLossPercentage = packetLoss, - lastSuccessfulPing = lastSuccess, - consecutiveFailures = consecutiveFailures - ) - } else { - currentStats.copy( - consecutiveFailures = currentStats.consecutiveFailures + 1 - ) - } - - _serverStats.value = newStats - - // Quick detection of connection issues - notify after 2 consecutive failures - if (newStats.consecutiveFailures >= 2) { - onConnectionIssue?.invoke(newStats.consecutiveFailures) - } - } - } - - fun getConnectionQuality(): ConnectionQuality { - val stats = _serverStats.value - val latestPing = _latestPing.value - - return when { - latestPing == null -> ConnectionQuality.UNKNOWN - !latestPing.isSuccessful || stats.consecutiveFailures > 2 -> ConnectionQuality.POOR - stats.averageLatencyMs > 1500 -> ConnectionQuality.POOR // >1500ms = Poor - stats.averageLatencyMs > 1000 -> ConnectionQuality.FAIR // 1000-1500ms = Fair - stats.averageLatencyMs > 600 -> ConnectionQuality.GOOD // 600-1000ms = Good - else -> ConnectionQuality.EXCELLENT // <600ms = Excellent - } - } - - fun reset() { - synchronized(pingHistory) { - pingHistory.clear() - _serverStats.value = ServerStats() - _latestPing.value = null - } - } -} - -enum class ConnectionQuality(val color: Int) { - EXCELLENT(0xFF4CAF50.toInt()), // Green - GOOD(0xFF8BC34A.toInt()), // Light Green - FAIR(0xFFFF9800.toInt()), // Orange - POOR(0xFFF44336.toInt()), // Red - UNKNOWN(0xFF9E9E9E.toInt()); // Gray - - fun getDisplayName(context: Context): String { - return when (this) { - EXCELLENT -> context.getString(R.string.quality_excellent) - GOOD -> context.getString(R.string.quality_good) - FAIR -> context.getString(R.string.quality_fair) - POOR -> context.getString(R.string.quality_poor) - UNKNOWN -> context.getString(R.string.quality_unknown) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/network/SshAlgorithmManager.kt b/app/src/main/java/com/example/sshproxy/network/SshAlgorithmManager.kt deleted file mode 100644 index 896ed94..0000000 --- a/app/src/main/java/com/example/sshproxy/network/SshAlgorithmManager.kt +++ /dev/null @@ -1,258 +0,0 @@ -package com.example.sshproxy.network - -import android.util.Log -import net.schmizz.sshj.Config -import net.schmizz.sshj.DefaultConfig -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Factory -import net.schmizz.sshj.transport.cipher.Cipher -import net.schmizz.sshj.transport.kex.KeyExchange -import net.schmizz.sshj.transport.mac.MAC -import kotlin.system.measureTimeMillis - -data class SshAlgorithms( - val cipher: String? = null, - val kex: String? = null, - val mac: String? = null -) - -data class AlgorithmPerformance( - val algorithm: String, - val averageTimeMs: Long, - val isSupported: Boolean -) - -class SshAlgorithmManager { - companion object { - private const val TAG = "SshAlgorithmManager" - - // Быстрые алгоритмы в порядке предпочтения (скорость) - private val FAST_CIPHERS = listOf( - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-cbc", - "aes192-cbc", - "aes256-cbc" - ) - - private val FAST_KEX = listOf( - "diffie-hellman-group14-sha256", - "diffie-hellman-group16-sha512", - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521" - ) - - private val FAST_MAC = listOf( - "hmac-sha2-256", - "hmac-sha2-512", - "hmac-sha1" - ) - } - - /** - * Получить список всех поддерживаемых алгоритмов из DefaultConfig - */ - fun getSupportedAlgorithms(): SshAlgorithms { - val defaultConfig = DefaultConfig() - - val ciphers = defaultConfig.cipherFactories.map { it.name } - val kexAlgorithms = defaultConfig.keyExchangeFactories.map { it.name } - val macAlgorithms = defaultConfig.macFactories.map { it.name } - - Log.d(TAG, "Supported ciphers: $ciphers") - Log.d(TAG, "Supported KEX: $kexAlgorithms") - Log.d(TAG, "Supported MAC: $macAlgorithms") - - return SshAlgorithms( - cipher = ciphers.joinToString(","), - kex = kexAlgorithms.joinToString(","), - mac = macAlgorithms.joinToString(",") - ) - } - - /** - * Получить оптимальные быстрые алгоритмы из поддерживаемых - */ - fun getFastAlgorithms(): SshAlgorithms { - val defaultConfig = DefaultConfig() - - val supportedCiphers = defaultConfig.cipherFactories.map { it.name } - val supportedKex = defaultConfig.keyExchangeFactories.map { it.name } - val supportedMac = defaultConfig.macFactories.map { it.name } - - Log.d(TAG, "All supported ciphers: ${supportedCiphers.joinToString(", ")}") - Log.d(TAG, "All supported KEX: ${supportedKex.joinToString(", ")}") - Log.d(TAG, "All supported MAC: ${supportedMac.joinToString(", ")}") - - // Выбираем первый доступный быстрый алгоритм из каждой категории - val fastCipher = FAST_CIPHERS.firstOrNull { it in supportedCiphers } - val selectedCipher = fastCipher ?: supportedCiphers.firstOrNull() - - val fastKex = FAST_KEX.firstOrNull { it in supportedKex } - val selectedKex = fastKex ?: supportedKex.firstOrNull() - - val fastMac = FAST_MAC.firstOrNull { it in supportedMac } - val selectedMac = fastMac ?: supportedMac.firstOrNull() - - // Логируем что выбрали и почему - Log.d(TAG, "Cipher selection: preferred=${FAST_CIPHERS.joinToString(", ")}") - Log.d(TAG, "Cipher selected: $selectedCipher ${if (fastCipher != null) "(fast)" else "(fallback)"}") - - Log.d(TAG, "KEX selection: preferred=${FAST_KEX.joinToString(", ")}") - Log.d(TAG, "KEX selected: $selectedKex ${if (fastKex != null) "(fast)" else "(fallback)"}") - - Log.d(TAG, "MAC selection: preferred=${FAST_MAC.joinToString(", ")}") - Log.d(TAG, "MAC selected: $selectedMac ${if (fastMac != null) "(fast)" else "(fallback)"}") - - return SshAlgorithms( - cipher = selectedCipher, - kex = selectedKex, - mac = selectedMac - ) - } - - /** - * Создать кастомный Config с указанными алгоритмами - */ - fun createCustomConfig(algorithms: SshAlgorithms): Config { - val defaultConfig = DefaultConfig() - - Log.d(TAG, "Creating custom SSH config with algorithms:") - Log.d(TAG, " - Preferred cipher: ${algorithms.cipher}") - Log.d(TAG, " - Preferred KEX: ${algorithms.kex}") - Log.d(TAG, " - Preferred MAC: ${algorithms.mac}") - - return object : Config by defaultConfig { - override fun getCipherFactories(): List> { - val allFactories = defaultConfig.cipherFactories - return if (algorithms.cipher != null) { - // Ставим предпочтительный алгоритм первым - val preferredFactory = allFactories.find { it.name == algorithms.cipher } - if (preferredFactory != null) { - listOf(preferredFactory) + allFactories.filter { it.name != algorithms.cipher } - } else { - allFactories - } - } else { - allFactories - } - } - - override fun getKeyExchangeFactories(): List> { - val allFactories = defaultConfig.keyExchangeFactories - return if (algorithms.kex != null) { - val preferredFactory = allFactories.find { it.name == algorithms.kex } - if (preferredFactory != null) { - listOf(preferredFactory) + allFactories.filter { it.name != algorithms.kex } - } else { - allFactories - } - } else { - allFactories - } - } - - override fun getMACFactories(): List> { - val allFactories = defaultConfig.macFactories - return if (algorithms.mac != null) { - val preferredFactory = allFactories.find { it.name == algorithms.mac } - if (preferredFactory != null) { - listOf(preferredFactory) + allFactories.filter { it.name != algorithms.mac } - } else { - allFactories - } - } else { - allFactories - } - } - } - } - - /** - * Проверить совместимость алгоритмов с сервером - * Возвращает фактически согласованные алгоритмы после подключения - */ - suspend fun testAlgorithmCompatibility( - host: String, - port: Int, - username: String, - keyProvider: net.schmizz.sshj.userauth.keyprovider.KeyProvider, - algorithms: SshAlgorithms, - timeoutMs: Int = 15000 - ): Result { - return try { - val config = createCustomConfig(algorithms) - val sshClient = SSHClient(config) - - val connectionTime = measureTimeMillis { - sshClient.use { client -> - client.connectTimeout = timeoutMs - client.connect(host, port) - client.authPublickey(username, keyProvider) - } - } - - Log.d(TAG, "Algorithm test successful in ${connectionTime}ms") - - // В реальности нужно было бы получить согласованные алгоритмы из транспорта SSH - // Но для упрощения возвращаем исходные алгоритмы как успешные - Result.success(algorithms) - - } catch (e: Exception) { - Log.e(TAG, "Algorithm compatibility test failed", e) - Result.failure(e) - } - } - - /** - * Автоматически выбрать и протестировать лучшие алгоритмы для сервера - */ - suspend fun autoSelectOptimalAlgorithms( - host: String, - port: Int, - username: String, - keyProvider: net.schmizz.sshj.userauth.keyprovider.KeyProvider - ): SshAlgorithms? { - val fastAlgorithms = getFastAlgorithms() - - // Пробуем быстрые алгоритмы - val result = testAlgorithmCompatibility(host, port, username, keyProvider, fastAlgorithms) - - return if (result.isSuccess) { - Log.d(TAG, "Fast algorithms work for $host:$port") - fastAlgorithms - } else { - Log.w(TAG, "Fast algorithms failed for $host:$port, using defaults") - null // Возвращаем null чтобы использовать default config - } - } - - /** - * Получить человекочитаемые названия алгоритмов для UI - */ - fun getAlgorithmDisplayNames(): Map { - return mapOf( - // Ciphers - "aes128-ctr" to "AES-128 CTR (Fast)", - "aes192-ctr" to "AES-192 CTR (Fast)", - "aes256-ctr" to "AES-256 CTR (Fast)", - "aes128-cbc" to "AES-128 CBC", - "aes192-cbc" to "AES-192 CBC", - "aes256-cbc" to "AES-256 CBC", - - // KEX - "diffie-hellman-group14-sha256" to "DH Group14 SHA256", - "diffie-hellman-group16-sha512" to "DH Group16 SHA512", - "ecdh-sha2-nistp256" to "ECDH P-256 (Fast)", - "ecdh-sha2-nistp384" to "ECDH P-384", - "ecdh-sha2-nistp521" to "ECDH P-521", - - // MAC - "hmac-sha2-256" to "HMAC-SHA2-256 (Fast)", - "hmac-sha2-512" to "HMAC-SHA2-512", - "hmac-sha1" to "HMAC-SHA1" - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/payload/PayloadProcessor.java b/app/src/main/java/com/example/sshproxy/payload/PayloadProcessor.java new file mode 100644 index 0000000..1b4173f --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/payload/PayloadProcessor.java @@ -0,0 +1,53 @@ +package com.example.sshproxy.payload; + +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +public class PayloadProcessor { + + public static int rotateIndex = 0; + + public static String processPayload(String template, String host, String port, String proxy, String userAgent) { + String payload = template; + + // Basic replacements + payload = payload.replace("[crlf]", "\r\n"); + payload = payload.replace("[host]", host); + payload = payload.replace("[rlb]", host); + payload = payload.replace("[port]", port); + + if (proxy != null && !proxy.isEmpty()) { + payload = payload.replace("[proxy]", proxy); + } + + if (userAgent == null || userAgent.isEmpty()) { + userAgent = "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36"; + } + payload = payload.replace("[ua]", userAgent); + + payload = payload.replace("[https/host]", "https://" + host); + + // Normalize raw newlines + payload = payload.replaceAll("(? 0) { + Thread.sleep(splitDelayMs) + } + } + LogManager.addLog("[ProxyConnector] Payload sent (${parts.size} parts)") + } else { + LogManager.addLog("[ProxyConnector] No payload to send (usePayload=false)") + } + + // ---- READ THE RESPONSE ---- + try { + socket.soTimeout = 10000 // 10 seconds for the response + val reader = BufferedReader(InputStreamReader(input)) + + // Blocking read for the status line – do NOT use reader.ready() here! + var statusLine: String? = reader.readLine() + LogManager.addLog("[ProxyConnector] Server status: $statusLine") + + // If statusLine is null or empty, try reading again with a short wait + if (statusLine == null || statusLine.isEmpty()) { + Thread.sleep(200) + statusLine = reader.readLine() + LogManager.addLog("[ProxyConnector] Delayed server status: $statusLine") + } + + // Now read headers until an empty line + var line: String? + while (reader.ready().also { line = reader.readLine() } && line != null) { + if (line!!.isEmpty()) { + LogManager.addLog("[ProxyConnector] End of HTTP headers") + break + } + LogManager.addLog("[ProxyConnector] Response header: $line") + if (line!!.startsWith("SSH-2.0")) { + LogManager.addLog("[ProxyConnector] SSH banner detected – stopping read") + break + } + } + + // Validate response + if (statusLine != null) { + val isAccepted = statusLine.startsWith("HTTP/1.1 2") || + statusLine.startsWith("HTTP/1.1 3") || + statusLine.contains("101") || + statusLine.contains("SSH-2.0") + if (!isAccepted) { + LogManager.addLog("[ProxyConnector] Invalid response: $statusLine") + socket.close() + throw ProxyConnectionException("Server returned $statusLine") + } + LogManager.addLog("[ProxyConnector] Server status accepted ($statusLine) – continuing to SSH") + } else { + LogManager.addLog("[ProxyConnector] No status line – assuming success") + } + + } catch (e: java.net.SocketTimeoutException) { + LogManager.addLog("[ProxyConnector] Response read timed out – assuming SSH handshake can start") + } catch (e: Exception) { + LogManager.addLog("[ProxyConnector] Error reading response: ${e.message} – continuing") + } + + // ---- Reset timeout for SSH ---- + socket.soTimeout = 30000 + + LogManager.addLog("[ProxyConnector] Tunnel established successfully") + return socket + } + + // ---- Direct fallback (unchanged) ---- + private fun connectDirect( + sshHost: String, + sshPort: Int, + proxyHost: String, + proxyPort: Int, + payload: String, + userAgent: String, + connectTimeout: Int, + splitDelayMs: Long, + sslForSSH: Boolean, + usePayload: Boolean + ): Socket { + LogManager.addLog("[ProxyConnector] Direct connection to $sshHost:$sshPort" + + if (sslForSSH) " (SSL)" else "") + + val socket: Socket = if (sslForSSH) { + try { + val factory = SSLSocketFactory.getDefault() + val sslSocket = factory.createSocket(sshHost, sshPort) as SSLSocket + sslSocket.startHandshake() + sslSocket + } catch (e: Exception) { + throw ProxyConnectionException("SSL handshake failed: ${e.message}", e) + } + } else { + Socket() + } + + try { + if (!sslForSSH) { + socket.connect(InetSocketAddress(sshHost, sshPort), connectTimeout) + } + socket.tcpNoDelay = true + socket.keepAlive = true + } catch (e: Exception) { + socket.close() + throw ProxyConnectionException("Failed to connect to $sshHost:$sshPort", e) + } + + val output = socket.getOutputStream() + + if (usePayload && payload.isNotEmpty()) { + LogManager.addLog("[ProxyConnector] Sending direct payload (no CONNECT, usePayload=true)") + val proxyString = "$proxyHost:$proxyPort" + val processedPayload = PayloadProcessor.processPayload( + payload, + sshHost, + sshPort.toString(), + proxyString, + userAgent + ) + LogManager.addLog("[ProxyConnector] Processed payload (first 200 chars):\n${processedPayload.take(200)}...") + val parts = PayloadProcessor.splitPayload(processedPayload) + LogManager.addLog("[ProxyConnector] Split into ${parts.size} parts") + for ((index, part) in parts.withIndex()) { + output.write(part.toByteArray()) + output.flush() + if (index < parts.size - 1 && splitDelayMs > 0) { + Thread.sleep(splitDelayMs) + } + } + LogManager.addLog("[ProxyConnector] Direct payload sent (${parts.size} parts)") + } else { + LogManager.addLog("[ProxyConnector] Skipping payload (usePayload=false)") + } + + LogManager.addLog("[ProxyConnector] Direct connection established – returning socket for SSH immediately") + return socket + } + + private fun buildConnectRequest(sshHost: String, sshPort: Int, proxyHost: String, proxyPort: Int, auth: ProxyAuth?): String { + val sb = StringBuilder() + sb.append("CONNECT $sshHost:$sshPort HTTP/1.1\r\n") + sb.append("Host: $proxyHost:$proxyPort\r\n") + sb.append("User-Agent: Mozilla/5.0\r\n") + sb.append("Connection: keep-alive\r\n") + if (auth != null) { + val credentials = "${auth.username}:${auth.password}" + val encoded = Base64.getEncoder().encodeToString(credentials.toByteArray()) + sb.append("Proxy-Authorization: Basic $encoded\r\n") + } + sb.append("\r\n") + return sb.toString() + } + + private fun drainHttpHeaders(reader: BufferedReader) { + var line: String? + while (reader.ready().also { line = reader.readLine() } && line != null) { + if (line!!.isEmpty()) break + } + } + + private fun extractLocation(reader: BufferedReader): String? { + var line: String? + while (reader.ready().also { line = reader.readLine() } && line != null) { + val currentLine = line ?: continue + if (currentLine.startsWith("Location:", ignoreCase = true)) { + return currentLine.substringAfter(":").trim() + } + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/proxy/ProxyException.kt b/app/src/main/java/com/example/sshproxy/proxy/ProxyException.kt new file mode 100644 index 0000000..827bb78 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/proxy/ProxyException.kt @@ -0,0 +1,4 @@ +package com.example.sshproxy.proxy + +class ProxyConnectionException(message: String, cause: Throwable? = null) : + Exception(message, cause) \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/security/KeyPasswordKeystore.kt b/app/src/main/java/com/example/sshproxy/security/KeyPasswordKeystore.kt deleted file mode 100644 index 11ced4e..0000000 --- a/app/src/main/java/com/example/sshproxy/security/KeyPasswordKeystore.kt +++ /dev/null @@ -1,80 +0,0 @@ - -package com.example.sshproxy.security - -import android.content.Context - -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Base64 -import android.util.Log -import java.security.KeyStore -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.IvParameterSpec - -object KeyPasswordKeystore { - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val KEY_ALIAS_PREFIX = "pem_key_password_" - private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" - private const val PASSWORD_LENGTH = 100 - private const val CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.<>/?|" - private const val IV_LENGTH = 16 - - fun getOrCreatePassword(context: Context, keyId: String): String { - val keyAlias = KEY_ALIAS_PREFIX + keyId - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - if (keyStore.containsAlias(keyAlias)) { - val secretKey = keyStore.getKey(keyAlias, null) as SecretKey - val (iv, encrypted) = KeyPasswordStorage.getEncryptedPassword(context, keyId) - ?: return "" - return decryptPassword(secretKey, iv, encrypted) - } else { - val password = generatePassword() - val (secretKey, iv, encrypted) = generateSecretKeyAndEncryptPassword(keyAlias, password) - KeyPasswordStorage.storeEncryptedPassword(context, keyId, iv, encrypted) - return password - } - } - - private fun generateSecretKeyAndEncryptPassword(keyAlias: String, password: String): Triple { - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val keyGenParameterSpec = KeyGenParameterSpec.Builder( - keyAlias, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - .build() - keyGenerator.init(keyGenParameterSpec) - val secretKey = keyGenerator.generateKey() - val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - val iv = cipher.iv - val encrypted = cipher.doFinal(password.toByteArray(Charsets.UTF_8)) - return Triple(secretKey, iv, encrypted) - } - - private fun decryptPassword(secretKey: SecretKey, iv: ByteArray, encrypted: ByteArray): String { - val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) - val spec = javax.crypto.spec.GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - val plain = cipher.doFinal(encrypted) - return String(plain, Charsets.UTF_8) - } - - private fun generatePassword(): String { - val random = SecureRandom() - return (1..PASSWORD_LENGTH) - .map { CHARSET[random.nextInt(CHARSET.length)] } - .joinToString("") - } - - // generateSecretKey больше не используется - - // encryptAndStorePassword больше не используется - - // decryptPassword(secretKey) больше не используется -} diff --git a/app/src/main/java/com/example/sshproxy/security/KeyPasswordStorage.kt b/app/src/main/java/com/example/sshproxy/security/KeyPasswordStorage.kt deleted file mode 100644 index f227293..0000000 --- a/app/src/main/java/com/example/sshproxy/security/KeyPasswordStorage.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.sshproxy.security - -import android.content.Context -import android.content.SharedPreferences -import android.util.Base64 - -object KeyPasswordStorage { - // Для хранения зашифрованного PEM-файла (salt:iv:ciphertext в одной строке) - private const val PEM_PREFIX = "pem_" - fun storeEncryptedPem(context: Context, keyId: String, encryptedPem: String) { - getPrefs(context).edit().putString(PEM_PREFIX + keyId, encryptedPem).apply() - } - fun getEncryptedPem(context: Context, keyId: String): String? { - return getPrefs(context).getString(PEM_PREFIX + keyId, null) - } - private const val PREFS_NAME = "pem_key_password_storage" - private const val KEY_IV_PREFIX = "iv_" - private const val KEY_ENC_PREFIX = "enc_" - - private fun getPrefs(context: Context): SharedPreferences { - return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - } - - fun storeEncryptedPassword(context: Context, keyId: String, iv: ByteArray, encrypted: ByteArray) { - val prefs = getPrefs(context) - prefs.edit() - .putString(KEY_IV_PREFIX + keyId, Base64.encodeToString(iv, Base64.NO_WRAP)) - .putString(KEY_ENC_PREFIX + keyId, Base64.encodeToString(encrypted, Base64.NO_WRAP)) - .apply() - } - - fun getEncryptedPassword(context: Context, keyId: String): Pair? { - val prefs = getPrefs(context) - val ivB64 = prefs.getString(KEY_IV_PREFIX + keyId, null) - val encB64 = prefs.getString(KEY_ENC_PREFIX + keyId, null) - if (ivB64 != null && encB64 != null) { - return Pair( - Base64.decode(ivB64, Base64.NO_WRAP), - Base64.decode(encB64, Base64.NO_WRAP) - ) - } - return null - } -} diff --git a/app/src/main/java/com/example/sshproxy/security/KeystoreManager.kt b/app/src/main/java/com/example/sshproxy/security/KeystoreManager.kt deleted file mode 100644 index ac3441f..0000000 --- a/app/src/main/java/com/example/sshproxy/security/KeystoreManager.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.example.sshproxy.security - -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Log -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec - -/** - * Manages encryption/decryption using Android Keystore for secure SSH private key storage - */ -class KeystoreManager { - - companion object { - private const val TAG = "KeystoreManager" - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val KEY_ALIAS_PREFIX = "ssh_key_" - private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" - private const val GCM_IV_LENGTH = 12 - private const val GCM_TAG_LENGTH = 128 - } - - private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { - load(null) - } - - /** - * Generate or get existing encryption key for SSH private key - */ - private fun getOrCreateSecretKey(keyId: String): SecretKey { - val keyAlias = "$KEY_ALIAS_PREFIX$keyId" - - return if (keyStore.containsAlias(keyAlias)) { - keyStore.getKey(keyAlias, null) as SecretKey - } else { - generateSecretKey(keyAlias) - } - } - - /** - * Generate new AES key in Android Keystore - */ - private fun generateSecretKey(keyAlias: String): SecretKey { - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val keyGenParameterSpec = KeyGenParameterSpec.Builder( - keyAlias, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - // Require user authentication for key access (optional - can be enabled for extra security) - // .setUserAuthenticationRequired(true) - // .setUserAuthenticationValidityDurationSeconds(300) // 5 minutes - .build() - - keyGenerator.init(keyGenParameterSpec) - return keyGenerator.generateKey() - } - - /** - * Encrypt private key data - * @param keyId SSH key identifier - * @param plaintext Private key content as byte array - * @return EncryptedData containing ciphertext and IV - */ - fun encryptPrivateKey(keyId: String, plaintext: ByteArray): EncryptedData { - try { - val secretKey = getOrCreateSecretKey(keyId) - val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - val iv = cipher.iv - val ciphertext = cipher.doFinal(plaintext) - - Log.d(TAG, "Successfully encrypted private key for keyId: $keyId") - return EncryptedData(ciphertext, iv) - } catch (e: Exception) { - Log.e(TAG, "Failed to encrypt private key for keyId: $keyId", e) - throw SecurityException("Failed to encrypt private key", e) - } - } - - /** - * Decrypt private key data - * @param keyId SSH key identifier - * @param encryptedData EncryptedData containing ciphertext and IV - * @return Decrypted private key content as byte array - */ - fun decryptPrivateKey(keyId: String, encryptedData: EncryptedData): ByteArray { - try { - val secretKey = getOrCreateSecretKey(keyId) - val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) - val spec = GCMParameterSpec(GCM_TAG_LENGTH, encryptedData.iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - - val plaintext = cipher.doFinal(encryptedData.ciphertext) - Log.d(TAG, "Successfully decrypted private key for keyId: $keyId") - return plaintext - } catch (e: Exception) { - Log.e(TAG, "Failed to decrypt private key for keyId: $keyId", e) - throw SecurityException("Failed to decrypt private key", e) - } - } - - /** - * Delete encryption key from Android Keystore - * @param keyId SSH key identifier - */ - fun deleteEncryptionKey(keyId: String) { - try { - val keyAlias = "$KEY_ALIAS_PREFIX$keyId" - if (keyStore.containsAlias(keyAlias)) { - keyStore.deleteEntry(keyAlias) - Log.d(TAG, "Deleted encryption key for keyId: $keyId") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to delete encryption key for keyId: $keyId", e) - } - } - - /** - * Check if encryption key exists for given SSH key ID - */ - fun hasEncryptionKey(keyId: String): Boolean { - return try { - val keyAlias = "$KEY_ALIAS_PREFIX$keyId" - keyStore.containsAlias(keyAlias) - } catch (e: Exception) { - Log.e(TAG, "Failed to check encryption key existence for keyId: $keyId", e) - false - } - } - - /** - * Data class to hold encrypted content and initialization vector - */ - data class EncryptedData( - val ciphertext: ByteArray, - val iv: ByteArray - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as EncryptedData - - if (!ciphertext.contentEquals(other.ciphertext)) return false - if (!iv.contentEquals(other.iv)) return false - - return true - } - - override fun hashCode(): Int { - var result = ciphertext.contentHashCode() - result = 31 * result + iv.contentHashCode() - return result - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/security/KnownHostsManager.kt b/app/src/main/java/com/example/sshproxy/security/KnownHostsManager.kt deleted file mode 100644 index a12a87f..0000000 --- a/app/src/main/java/com/example/sshproxy/security/KnownHostsManager.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.example.sshproxy.security - -import android.content.Context -import android.util.Log -import java.io.File -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap - -/** - * Manages known SSH host keys for fingerprint validation - * Similar to ~/.ssh/known_hosts but with Android-specific enhancements - */ -class KnownHostsManager(private val context: Context) { - - companion object { - private const val TAG = "KnownHostsManager" - private const val KNOWN_HOSTS_FILE = "known_hosts" - } - - private val knownHosts = ConcurrentHashMap() - private val knownHostsFile = File(context.filesDir, KNOWN_HOSTS_FILE) - - init { - loadKnownHosts() - } - - /** - * Data class representing a host key entry - */ - data class HostKeyInfo( - val hostname: String, - val port: Int, - val keyType: String, - val fingerprint: String, - val publicKey: ByteArray - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as HostKeyInfo - - if (hostname != other.hostname) return false - if (port != other.port) return false - if (keyType != other.keyType) return false - if (fingerprint != other.fingerprint) return false - if (!publicKey.contentEquals(other.publicKey)) return false - - return true - } - - override fun hashCode(): Int { - var result = hostname.hashCode() - result = 31 * result + port - result = 31 * result + keyType.hashCode() - result = 31 * result + fingerprint.hashCode() - result = 31 * result + publicKey.contentHashCode() - return result - } - } - - /** - * Check if we know this host and validate its key - * @param hostname Server hostname/IP - * @param port Server port - * @param publicKey Server's public key - * @param keyType Key type (ssh-rsa, ssh-ed25519, etc.) - * @return HostKeyValidationResult - */ - fun validateHostKey(hostname: String, port: Int, publicKey: ByteArray, keyType: String): HostKeyValidationResult { - val hostKey = generateHostKey(hostname, port) - val fingerprint = generateFingerprint(publicKey) - - Log.d(TAG, "Validating host key for $hostname:$port") - Log.d(TAG, "Key type: $keyType, Fingerprint: $fingerprint") - - val storedFingerprint = knownHosts[hostKey] - - return when { - storedFingerprint == null -> { - Log.i(TAG, "New host $hostname:$port - storing fingerprint") - HostKeyValidationResult.NEW_HOST - } - storedFingerprint == fingerprint -> { - Log.d(TAG, "Host key matches for $hostname:$port") - HostKeyValidationResult.VALID - } - else -> { - Log.w(TAG, "Host key mismatch for $hostname:$port!") - Log.w(TAG, "Stored: $storedFingerprint") - Log.w(TAG, "Received: $fingerprint") - HostKeyValidationResult.KEY_CHANGED - } - } - } - - /** - * Store a host key after validation - * @param hostname Server hostname/IP - * @param port Server port - * @param publicKey Server's public key - * @param keyType Key type - */ - fun storeHostKey(hostname: String, port: Int, publicKey: ByteArray, keyType: String) { - val hostKey = generateHostKey(hostname, port) - val fingerprint = generateFingerprint(publicKey) - - knownHosts[hostKey] = fingerprint - saveKnownHosts() - - Log.i(TAG, "Stored host key for $hostname:$port with fingerprint $fingerprint") - } - - /** - * Get stored fingerprint for a host - * @param hostname Server hostname/IP - * @param port Server port - * @return Stored fingerprint or null if not known - */ - fun getStoredFingerprint(hostname: String, port: Int): String? { - val hostKey = generateHostKey(hostname, port) - return knownHosts[hostKey] - } - - /** - * Remove a host key (for when user wants to reset/remove) - * @param hostname Server hostname/IP - * @param port Server port - */ - fun removeHostKey(hostname: String, port: Int) { - val hostKey = generateHostKey(hostname, port) - knownHosts.remove(hostKey) - saveKnownHosts() - Log.i(TAG, "Removed host key for $hostname:$port") - } - - /** - * Generate SHA-256 fingerprint from public key - */ - private fun generateFingerprint(publicKey: ByteArray): String { - return try { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(publicKey) - "SHA256:" + android.util.Base64.encodeToString(hash, android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) - } catch (e: Exception) { - Log.e(TAG, "Failed to generate fingerprint", e) - "ERROR:${e.message}" - } - } - - /** - * Generate host key identifier - */ - private fun generateHostKey(hostname: String, port: Int): String { - return if (port == 22) hostname else "[$hostname]:$port" - } - - /** - * Load known hosts from file - */ - private fun loadKnownHosts() { - try { - if (!knownHostsFile.exists()) { - Log.d(TAG, "Known hosts file does not exist, starting fresh") - return - } - - knownHostsFile.readLines().forEach { line -> - val trimmed = line.trim() - if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { - val parts = trimmed.split(" ", limit = 2) - if (parts.size == 2) { - knownHosts[parts[0]] = parts[1] - } - } - } - - Log.d(TAG, "Loaded ${knownHosts.size} known hosts") - } catch (e: Exception) { - Log.e(TAG, "Failed to load known hosts", e) - } - } - - /** - * Save known hosts to file - */ - private fun saveKnownHosts() { - try { - val content = knownHosts.entries.joinToString("\n") { "${it.key} ${it.value}" } - knownHostsFile.writeText(content) - Log.d(TAG, "Saved ${knownHosts.size} known hosts to file") - } catch (e: Exception) { - Log.e(TAG, "Failed to save known hosts", e) - } - } - - /** - * Result of host key validation - */ - enum class HostKeyValidationResult { - /** Host is new, key should be stored */ - NEW_HOST, - /** Host key matches stored fingerprint */ - VALID, - /** Host key has changed - potential security issue */ - KEY_CHANGED - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/security/PemAesUtil.kt b/app/src/main/java/com/example/sshproxy/security/PemAesUtil.kt deleted file mode 100644 index caf58d2..0000000 --- a/app/src/main/java/com/example/sshproxy/security/PemAesUtil.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.example.sshproxy.security - -import android.util.Base64 -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.SecretKeyFactory -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.PBEKeySpec -import javax.crypto.spec.SecretKeySpec - -object PemAesUtil { - private const val AES_MODE = "AES/CBC/PKCS5Padding" - private const val KEY_LENGTH = 256 - private const val ITERATION_COUNT = 100_000 - private const val SALT_LENGTH = 16 - private const val IV_LENGTH = 16 - private const val PASSWORD_LENGTH = 100 - private const val CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.<>/?|" - - fun generatePassword(): String { - val random = SecureRandom() - return (1..PASSWORD_LENGTH) - .map { CHARSET[random.nextInt(CHARSET.length)] } - .joinToString("") - } - - fun encryptPem(pem: String, password: String): String { - val salt = ByteArray(SALT_LENGTH) - val iv = ByteArray(IV_LENGTH) - SecureRandom().nextBytes(salt) - SecureRandom().nextBytes(iv) - val key = deriveKey(password, salt) - val cipher = Cipher.getInstance(AES_MODE) - cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv)) - val ciphertext = cipher.doFinal(pem.toByteArray(Charsets.UTF_8)) - // Формат: salt:iv:ciphertext (все base64) - return Base64.encodeToString(salt, Base64.NO_WRAP) + ":" + - Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + - Base64.encodeToString(ciphertext, Base64.NO_WRAP) - } - - fun decryptPem(encrypted: String, password: String): String { - val parts = encrypted.split(":") - require(parts.size == 3) { "Invalid encrypted PEM format" } - val salt = Base64.decode(parts[0], Base64.NO_WRAP) - val iv = Base64.decode(parts[1], Base64.NO_WRAP) - val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP) - val key = deriveKey(password, salt) - val cipher = Cipher.getInstance(AES_MODE) - cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) - val plain = cipher.doFinal(ciphertext) - return String(plain, Charsets.UTF_8) - } - - private fun deriveKey(password: String, salt: ByteArray): SecretKeySpec { - val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - val spec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH) - val tmp = factory.generateSecret(spec) - return SecretKeySpec(tmp.encoded, "AES") - } -} diff --git a/app/src/main/java/com/example/sshproxy/security/SecureHostKeyVerifier.kt b/app/src/main/java/com/example/sshproxy/security/SecureHostKeyVerifier.kt deleted file mode 100644 index 7529520..0000000 --- a/app/src/main/java/com/example/sshproxy/security/SecureHostKeyVerifier.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.example.sshproxy.security - -import android.content.Context -import android.util.Log -import com.example.sshproxy.AppLog -import net.schmizz.sshj.transport.verification.HostKeyVerifier -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -/** - * Custom host key verifier that implements known_hosts validation - * Replaces the promiscuous verifier with proper security checks - */ -class SecureHostKeyVerifier(private val context: Context) : HostKeyVerifier { - - companion object { - private const val TAG = "SecureHostKeyVerifier" - } - - private val knownHostsManager = KnownHostsManager(context) - - // Callback for when host key validation fails - var onHostKeyMismatch: (suspend (hostname: String, port: Int, fingerprint: String, storedFingerprint: String) -> Boolean)? = null - - override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList { - // Return empty list - we don't restrict algorithms, just validate keys - return mutableListOf() - } - - override fun verify(hostname: String?, port: Int, key: java.security.PublicKey?): Boolean { - if (hostname == null || key == null) { - Log.e(TAG, "Invalid hostname or key provided") - return false - } - - return try { - val keyBytes = key.encoded - val keyType = determineKeyType(key) - - Log.d(TAG, "Verifying host key for $hostname:$port (type: $keyType)") - - when (val result = knownHostsManager.validateHostKey(hostname, port, keyBytes, keyType)) { - KnownHostsManager.HostKeyValidationResult.NEW_HOST -> { - // First connection - store the key - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - AppLog.log("New SSH server $hostname:$port - fingerprint stored") - true - } - - KnownHostsManager.HostKeyValidationResult.VALID -> { - // Key matches - connection is safe - Log.d(TAG, "Host key validation successful for $hostname:$port") - true - } - - KnownHostsManager.HostKeyValidationResult.KEY_CHANGED -> { - // Key changed - potential security issue - val fingerprint = generateFingerprint(keyBytes) - val storedFingerprint = knownHostsManager.getStoredFingerprint(hostname, port) ?: "unknown" - - Log.w(TAG, "Host key changed for $hostname:$port!") - AppLog.log("WARNING: Host key changed for $hostname:$port") - AppLog.log("Stored fingerprint: $storedFingerprint") - AppLog.log("Received fingerprint: $fingerprint") - - // For now, return false - we need async user confirmation - // The service will handle this differently using verifyWithUserConfirmation - false - } - } - } catch (e: Exception) { - Log.e(TAG, "Host key verification failed", e) - AppLog.log("Host key verification error: ${e.message}") - false - } - } - - /** - * Verify host key with user confirmation for changed keys - * This is used when we need to ask the user about key changes - */ - suspend fun verifyWithUserConfirmation(hostname: String, port: Int, key: java.security.PublicKey): Boolean { - val keyBytes = key.encoded - val keyType = determineKeyType(key) - - when (val result = knownHostsManager.validateHostKey(hostname, port, keyBytes, keyType)) { - KnownHostsManager.HostKeyValidationResult.NEW_HOST -> { - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - AppLog.log("New SSH server $hostname:$port - fingerprint stored") - return true - } - - KnownHostsManager.HostKeyValidationResult.VALID -> { - return true - } - - KnownHostsManager.HostKeyValidationResult.KEY_CHANGED -> { - val fingerprint = generateFingerprint(keyBytes) - val storedFingerprint = knownHostsManager.getStoredFingerprint(hostname, port) ?: "unknown" - - // Ask user for confirmation - val userAccepted = onHostKeyMismatch?.invoke(hostname, port, fingerprint, storedFingerprint) ?: false - - if (userAccepted) { - // User accepted - update the stored key - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - AppLog.log("Host key updated for $hostname:$port with user confirmation") - return true - } else { - AppLog.log("Connection rejected - host key change not accepted") - return false - } - } - } - } - - /** - * Get stored fingerprint for a server (for display purposes) - */ - fun getServerFingerprint(hostname: String, port: Int): String? { - return knownHostsManager.getStoredFingerprint(hostname, port) - } - - /** - * Remove stored host key (when user wants to reset) - */ - fun removeHostKey(hostname: String, port: Int) { - knownHostsManager.removeHostKey(hostname, port) - } - - /** - * Determine SSH key type from PublicKey - */ - private fun determineKeyType(key: java.security.PublicKey): String { - return when (key.algorithm.lowercase()) { - "rsa" -> "ssh-rsa" - "ec" -> "ecdsa-sha2-nistp256" // Assuming P-256 curve - "eddsa", "ed25519" -> "ssh-ed25519" - else -> "ssh-${key.algorithm.lowercase()}" - } - } - - /** - * Generate SHA-256 fingerprint from public key bytes - */ - private fun generateFingerprint(publicKey: ByteArray): String { - return try { - val digest = java.security.MessageDigest.getInstance("SHA-256") - val hash = digest.digest(publicKey) - "SHA256:" + android.util.Base64.encodeToString(hash, android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) - } catch (e: Exception) { - "ERROR:${e.message}" - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/security/SecurityNotificationManager.kt b/app/src/main/java/com/example/sshproxy/security/SecurityNotificationManager.kt deleted file mode 100644 index 53a2d96..0000000 --- a/app/src/main/java/com/example/sshproxy/security/SecurityNotificationManager.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.example.sshproxy.security - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.core.app.NotificationCompat -import com.example.sshproxy.MainActivity -import com.example.sshproxy.R - -/** - * Manages security-related notifications for host key changes and other critical events - */ -class SecurityNotificationManager(private val context: Context) { - - companion object { - private const val SECURITY_CHANNEL_ID = "security_alerts" - private const val HOST_KEY_CHANGE_ID = 1001 - } - - private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - init { - createSecurityChannel() - } - - /** - * Show notification about host key change that requires user attention - */ - fun showHostKeyChangeNotification(hostname: String, port: Int) { - val hostDisplay = if (port == 22) hostname else "$hostname:$port" - - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra("show_security_alert", true) - putExtra("hostname", hostname) - putExtra("port", port) - } - - val pendingIntent = PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(context, SECURITY_CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_dialog_alert) - .setContentTitle(context.getString(R.string.security_warning)) - .setContentText(context.getString(R.string.host_key_changed_notification, hostDisplay)) - .setStyle(NotificationCompat.BigTextStyle() - .bigText(context.getString(R.string.host_key_changed_notification_detail, hostDisplay))) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_ALARM) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .build() - - notificationManager.notify(HOST_KEY_CHANGE_ID, notification) - } - - /** - * Clear security notifications - */ - fun clearSecurityNotifications() { - notificationManager.cancel(HOST_KEY_CHANGE_ID) - } - - private fun createSecurityChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - SECURITY_CHANNEL_ID, - context.getString(R.string.security_alerts_channel), - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = context.getString(R.string.security_alerts_channel_description) - enableLights(true) - enableVibration(true) - } - - notificationManager.createNotificationChannel(channel) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/service/ConnectionHealthMonitor.kt b/app/src/main/java/com/example/sshproxy/service/ConnectionHealthMonitor.kt deleted file mode 100644 index c78d5db..0000000 --- a/app/src/main/java/com/example/sshproxy/service/ConnectionHealthMonitor.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.example.sshproxy.service - -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.atomic.AtomicInteger -import kotlin.math.min -import kotlin.math.pow - -/** - * Monitors SSH connection health and manages automatic reconnection with exponential backoff - */ -class ConnectionHealthMonitor( - private val healthCheckIntervalMs: Long = 10_000L, // 10 seconds - more aggressive monitoring - private val maxReconnectAttempts: Int = 10, - private val initialBackoffMs: Long = 1_000L, // 1 second - private val maxBackoffMs: Long = 300_000L, // 5 minutes - private val backoffMultiplier: Double = 2.0 -) { - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) - val connectionState: StateFlow = _connectionState.asStateFlow() - - private val reconnectAttempts = AtomicInteger(0) - private var healthCheckJob: Job? = null - private var reconnectJob: Job? = null - - enum class ConnectionState { - CONNECTED, - DISCONNECTED, - RECONNECTING, - FAILED - } - - data class ReconnectStrategy( - val attempt: Int, - val delayMs: Long, - val shouldRetry: Boolean - ) - - /** - * Start monitoring connection health - */ - fun startMonitoring( - scope: CoroutineScope, - isConnectionAlive: suspend () -> Boolean, - onReconnect: suspend () -> Boolean - ) { - stopMonitoring() - - healthCheckJob = scope.launch { - while (isActive) { - delay(healthCheckIntervalMs) - - if (_connectionState.value == ConnectionState.CONNECTED) { - try { - val isAlive = withTimeout(3000L) { isConnectionAlive() } - - if (!isAlive) { - handleConnectionLost(scope, onReconnect) - } else { - // Connection is healthy, reset attempts counter - reconnectAttempts.set(0) - } - } catch (e: Exception) { - handleConnectionLost(scope, onReconnect) - } - } - } - } - } - - /** - * Handle connection loss and initiate reconnection - */ - private fun handleConnectionLost( - scope: CoroutineScope, - onReconnect: suspend () -> Boolean - ) { - if (_connectionState.value == ConnectionState.RECONNECTING) { - return // Already reconnecting - } - - _connectionState.value = ConnectionState.RECONNECTING - - reconnectJob?.cancel() - reconnectJob = scope.launch { - while (isActive) { - val strategy = getReconnectStrategy() - - if (!strategy.shouldRetry) { - _connectionState.value = ConnectionState.FAILED - break - } - - delay(strategy.delayMs) - - try { - val success = withTimeout(10_000L) { onReconnect() } - - if (success) { - _connectionState.value = ConnectionState.CONNECTED - reconnectAttempts.set(0) - break - } - } catch (e: Exception) { - // Reconnection failed, will retry - } - - reconnectAttempts.incrementAndGet() - } - } - } - - /** - * Calculate reconnection strategy with exponential backoff - */ - private fun getReconnectStrategy(): ReconnectStrategy { - val attempt = reconnectAttempts.get() - val shouldRetry = attempt < maxReconnectAttempts - - val delayMs = if (shouldRetry) { - val exponentialDelay = initialBackoffMs * backoffMultiplier.pow(attempt.toDouble()) - min(exponentialDelay.toLong(), maxBackoffMs) - } else { - 0L - } - - return ReconnectStrategy( - attempt = attempt, - delayMs = delayMs, - shouldRetry = shouldRetry - ) - } - - /** - * Notify that connection has been established - */ - fun onConnectionEstablished() { - _connectionState.value = ConnectionState.CONNECTED - reconnectAttempts.set(0) - } - - /** - * Notify that connection has been lost - */ - fun onConnectionLost() { - _connectionState.value = ConnectionState.DISCONNECTED - } - - /** - * Stop monitoring and cancel all jobs - */ - fun stopMonitoring() { - healthCheckJob?.cancel() - healthCheckJob = null - reconnectJob?.cancel() - reconnectJob = null - _connectionState.value = ConnectionState.DISCONNECTED - reconnectAttempts.set(0) - } - - /** - * Reset reconnection attempts counter - */ - fun resetReconnectAttempts() { - reconnectAttempts.set(0) - } - - /** - * Get current number of reconnection attempts - */ - fun getReconnectAttempts(): Int = reconnectAttempts.get() -} diff --git a/app/src/main/java/com/example/sshproxy/tun2socks/HevSocks5Tunnel.kt b/app/src/main/java/com/example/sshproxy/tun2socks/HevSocks5Tunnel.kt new file mode 100644 index 0000000..c65f706 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/tun2socks/HevSocks5Tunnel.kt @@ -0,0 +1,19 @@ +package com.example.sshproxy.tun2socks + +import com.example.sshproxy.LogManager + +object HevSocks5Tunnel { + // Native methods – match the ones from TProxyService + external fun TProxyStartService(configPath: String, tunFd: Int): Boolean + external fun TProxyStopService(): Boolean + external fun TProxyIsRunning(): Boolean + + init { + try { + System.loadLibrary("hev-socks5-tunnel") + LogManager.addLog("[hev-socks5-tunnel] Library loaded successfully") + } catch (e: UnsatisfiedLinkError) { + LogManager.addLog("[ERROR] Failed to load hev-socks5-tunnel: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/example/sshproxy/ui/dialogs/HostKeyChangeDialog.kt b/app/src/main/java/com/example/sshproxy/ui/dialogs/HostKeyChangeDialog.kt deleted file mode 100644 index 9839801..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/dialogs/HostKeyChangeDialog.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.example.sshproxy.ui.dialogs - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.example.sshproxy.R - -/** - * Dialog shown when a server's host key has changed - * This is a security-critical dialog that warns users about potential MITM attacks - */ -class HostKeyChangeDialog : DialogFragment() { - - companion object { - private const val ARG_HOSTNAME = "hostname" - private const val ARG_PORT = "port" - private const val ARG_NEW_FINGERPRINT = "new_fingerprint" - private const val ARG_STORED_FINGERPRINT = "stored_fingerprint" - - fun newInstance( - hostname: String, - port: Int, - newFingerprint: String, - storedFingerprint: String, - onResult: (Boolean) -> Unit - ): HostKeyChangeDialog { - val dialog = HostKeyChangeDialog() - dialog.onResult = onResult - dialog.arguments = Bundle().apply { - putString(ARG_HOSTNAME, hostname) - putInt(ARG_PORT, port) - putString(ARG_NEW_FINGERPRINT, newFingerprint) - putString(ARG_STORED_FINGERPRINT, storedFingerprint) - } - return dialog - } - } - - private var onResult: ((Boolean) -> Unit)? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val hostname = arguments?.getString(ARG_HOSTNAME) ?: "" - val port = arguments?.getInt(ARG_PORT) ?: 22 - val newFingerprint = arguments?.getString(ARG_NEW_FINGERPRINT) ?: "" - val storedFingerprint = arguments?.getString(ARG_STORED_FINGERPRINT) ?: "" - - val hostDisplay = if (port == 22) hostname else "$hostname:$port" - - val message = getString(R.string.host_key_changed_message, hostDisplay) + "\n\n" + - getString(R.string.stored_fingerprint) + "\n$storedFingerprint\n\n" + - getString(R.string.received_fingerprint) + "\n$newFingerprint\n\n" + - getString(R.string.host_key_change_warning) - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.security_warning) - .setMessage(message) - .setIcon(android.R.drawable.ic_dialog_alert) - .setPositiveButton(R.string.accept_and_continue) { _, _ -> - onResult?.invoke(true) - } - .setNegativeButton(R.string.reject_connection) { _, _ -> - onResult?.invoke(false) - } - .setCancelable(false) // Force user to make a decision - .create() - } - - override fun onDestroy() { - super.onDestroy() - onResult = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/home/HomeFragment.kt b/app/src/main/java/com/example/sshproxy/ui/home/HomeFragment.kt deleted file mode 100644 index 55ee955..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/home/HomeFragment.kt +++ /dev/null @@ -1,485 +0,0 @@ -package com.example.sshproxy.ui.home - - -import android.animation.ValueAnimator -import android.content.Intent -import android.net.VpnService -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.TextView -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.R -import com.example.sshproxy.SshProxyService -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.IpLocationService -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.data.ConnectionState -import com.example.sshproxy.data.ConnectionStatus -import com.example.sshproxy.data.getDisplayStatus -import com.example.sshproxy.data.getConnectionDuration -import com.example.sshproxy.data.getPingDisplay -import com.example.sshproxy.data.getQualityColor -import com.example.sshproxy.databinding.FragmentHomeBinding -import com.example.sshproxy.network.HttpLatencyTester -import com.example.sshproxy.network.ConnectionQuality -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.net.HttpURLConnection -import java.net.URL - -class HomeFragment : Fragment() { - private var _binding: FragmentHomeBinding? = null - private val binding get() = _binding!! - - private val viewModel: HomeViewModel by viewModels { - HomeViewModelFactory( - ServerRepository(requireContext()), - PreferencesManager(requireContext()) - ) - } - private lateinit var keyManager: SshKeyManager - private var blinkAnimator: ValueAnimator? = null - private var durationUpdateHandler = Handler(Looper.getMainLooper()) - private var durationUpdateRunnable: Runnable? = null - - private val vpnPermissionLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - startVpnService() - } else { - Toast.makeText(context, getString(R.string.vpn_permission_denied), Toast.LENGTH_SHORT).show() - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentHomeBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - keyManager = SshKeyManager(requireContext(), KeyRepository(requireContext())) - - setupUI() - observeViewModel() - } - - override fun onResume() { - super.onResume() - // Resume duration updates if connected - val currentStatus = viewModel.connectionStatus.value - if (currentStatus.state == ConnectionState.CONNECTED) { - startDurationUpdates(currentStatus) - } - } - - override fun onPause() { - super.onPause() - // Pause duration updates to save battery - stopDurationUpdates() - } - - private fun setupUI() { - binding.btnConnect.setOnClickListener { - if (viewModel.isRunning.value) { - stopVpnService() - } else { - checkAndConnect() - } - } - - binding.tvSelectedServer.setOnClickListener { - showServerSelector() - } - - binding.btnTest.setOnClickListener { - testConnection() - } - - binding.btnRefreshIp.setOnClickListener { - // Force refresh when user explicitly requests it - IpLocationService.forceRefresh() - refreshIpInfo() - } - - // Показываем IP карточку и загружаем кэшированную информацию (без сетевых запросов) - binding.cardExternalIp.visibility = View.VISIBLE - loadCachedIpInfo() - } - - private fun observeViewModel() { - // Observe connection status with ping info - viewLifecycleOwner.lifecycleScope.launch { - viewModel.connectionStatus.collectLatest { status -> - if (_binding == null) return@collectLatest - - val isConnected = status.state == ConnectionState.CONNECTED - binding.btnConnect.isSelected = isConnected - - // Update connection status text - binding.tvConnectionStatus.text = status.getDisplayStatus(requireContext()) - - // Show/hide connection info card - binding.cardConnectionInfo.visibility = if (isConnected) View.VISIBLE else View.GONE - - // Update ping information - if (isConnected) { - binding.tvPing.text = status.getPingDisplay(requireContext()) ?: "--" - binding.tvQuality.text = status.connectionQuality.getDisplayName(requireContext()) - binding.tvConnectionDuration.text = status.getConnectionDuration() ?: "--" - - // Start periodic duration updates - startDurationUpdates(status) - - // Update quality indicator color - binding.viewQualityIndicator.setBackgroundTintList( - ContextCompat.getColorStateList(requireContext(), android.R.color.transparent) - ) - binding.viewQualityIndicator.setBackgroundColor(status.getQualityColor()) - - // Update ping text color based on HTTP latency (matched to quality logic) - val pingColor = when { - status.latestPing?.isSuccessful != true -> ContextCompat.getColor(requireContext(), R.color.error) - (status.latestPing.latencyMs) > 1500 -> ContextCompat.getColor(requireContext(), R.color.error) // >1500ms = Red (Poor) - (status.latestPing.latencyMs) > 1000 -> ContextCompat.getColor(requireContext(), R.color.warning) // 1000-1500ms = Orange (Fair) - (status.latestPing.latencyMs) > 600 -> ContextCompat.getColor(requireContext(), R.color.good) // 600-1000ms = Green (Good) - else -> ContextCompat.getColor(requireContext(), R.color.good) // <600ms = Green (Excellent) - } - binding.tvPing.setTextColor(pingColor) - - // Update quality text color matching ping color - binding.tvQuality.setTextColor(pingColor) - } else { - // Stop duration updates when disconnected - stopDurationUpdates() - } - - // Manage blinking animation - if (status.state == ConnectionState.CONNECTING || status.state == ConnectionState.DISCONNECTING || status.state == ConnectionState.RECONNECTING) { - startBlinking() - } else { - stopBlinking() - } - - // Update button icon - if (isConnected) { - binding.btnConnect.setIconResource(R.drawable.ic_stop) - } else { - binding.btnConnect.setIconResource(R.drawable.ic_power) - } - - // Update IP info on VPN connection state change (smart caching) - val sharedPrefs = requireActivity().getSharedPreferences("app_prefs", 0) - val lastKnownState = sharedPrefs.getString("last_connection_state", "") - val currentStateString = status.state.toString() - - if (lastKnownState != currentStateString) { - sharedPrefs.edit().putString("last_connection_state", currentStateString).apply() - - // Invalidate IP cache when VPN state changes - val isVpnConnected = status.state == ConnectionState.CONNECTED - IpLocationService.invalidateCacheOnVpnChange(isVpnConnected) - - when (status.state) { - ConnectionState.CONNECTED -> { - if (_binding != null) { - binding.tvCountryFlag.text = "⏳" - binding.tvCountryName.text = getString(R.string.vpn_connecting) - } - // Wait for VPN to stabilize, then refresh IP - viewLifecycleOwner.lifecycleScope.launch { - kotlinx.coroutines.delay(5000) - refreshIpInfo() - } - } - ConnectionState.DISCONNECTED -> { - if (_binding != null) { - binding.tvCountryFlag.text = "⏳" - binding.tvCountryName.text = getString(R.string.vpn_disconnecting) - } - // Shorter delay for disconnect, then refresh IP - viewLifecycleOwner.lifecycleScope.launch { - kotlinx.coroutines.delay(2000) - refreshIpInfo() - } - } - else -> { /* no action needed */ } - } - } - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.selectedServer.collectLatest { server -> - // Проверяем что binding еще валидный - if (_binding != null) { - binding.tvSelectedServer.text = server?.name ?: getString(R.string.no_server_selected) - } - } - } - } - - private fun showServerSelector() { - val servers = viewModel.servers.value - if (servers.isEmpty()) { - Toast.makeText(context, getString(R.string.no_servers_configured), Toast.LENGTH_SHORT).show() - return - } - - val serverNames = servers.map { it.name }.toTypedArray() - val currentIndex = servers.indexOfFirst { it.id == viewModel.selectedServer.value?.id }.takeIf { it >= 0 } ?: 0 - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.select_server)) - .setSingleChoiceItems(serverNames, currentIndex) { dialog, which -> - viewModel.selectServer(servers[which]) - dialog.dismiss() - } - .show() - } - - private fun checkAndConnect() { - lifecycleScope.launch { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - Toast.makeText(context, getString(R.string.requires_android_10), Toast.LENGTH_LONG).show() - return@launch - } - - if (!keyManager.hasKeyPair()) { - Toast.makeText(context, getString(R.string.no_ssh_key_configured), Toast.LENGTH_SHORT).show() - return@launch - } - - if (viewModel.selectedServer.value == null) { - Toast.makeText(context, getString(R.string.please_select_a_server), Toast.LENGTH_SHORT).show() - return@launch - } - - - val vpnIntent = VpnService.prepare(context) - if (vpnIntent != null) { - vpnPermissionLauncher.launch(vpnIntent) - } else { - startVpnService() - } - } - } - - private fun startVpnService() { - val server = viewModel.selectedServer.value ?: return - - val intent = Intent(context, SshProxyService::class.java).apply { - action = SshProxyService.ACTION_START - putExtra(SshProxyService.EXTRA_SERVER_ID, server.id) - } - requireContext().startService(intent) - } - - private fun stopVpnService() { - val intent = Intent(context, SshProxyService::class.java).apply { - action = SshProxyService.ACTION_STOP - } - requireContext().startService(intent) - } - - private val blinkingHandler = android.os.Handler(android.os.Looper.getMainLooper()) - - private fun startBlinking() { - blinkingHandler.removeCallbacksAndMessages(null) - if (blinkAnimator?.isRunning == true) return - - // Мигание через изменение цвета между серым и белым для максимального контраста - val grayColor = ContextCompat.getColor(requireContext(), R.color.vpn_button_disconnected) - val whiteColor = ContextCompat.getColor(requireContext(), android.R.color.white) - - blinkAnimator = ValueAnimator.ofArgb(grayColor, whiteColor).apply { - duration = 400 // Быстрее для более заметного мигания - repeatCount = ValueAnimator.INFINITE - repeatMode = ValueAnimator.REVERSE - addUpdateListener { animator -> - val color = animator.animatedValue as Int - binding.btnConnect.backgroundTintList = ContextCompat.getColorStateList(requireContext(), android.R.color.transparent) - binding.btnConnect.setBackgroundColor(color) - } - start() - } - } - - private fun stopBlinking() { - blinkingHandler.postDelayed({ - // Check if binding is still valid before accessing it - if (_binding != null) { - blinkAnimator?.cancel() - blinkAnimator = null - binding.btnConnect.alpha = 1f - - // Возвращаем нормальный background - binding.btnConnect.backgroundTintList = null - binding.btnConnect.setBackgroundResource(R.drawable.vpn_button_background) - } else { - // Just cancel the animator if binding is null - blinkAnimator?.cancel() - blinkAnimator = null - } - }, 1000) - } - - private fun testConnection() { - val bottomSheet = TestConnectionBottomSheet(viewModel) - bottomSheet.show(parentFragmentManager, TestConnectionBottomSheet.TAG) - } - - private fun startDurationUpdates(@Suppress("UNUSED_PARAMETER") initialStatus: ConnectionStatus) { - // Stop any existing updates - stopDurationUpdates() - - // Create a runnable that updates the duration every second - durationUpdateRunnable = object : Runnable { - override fun run() { - if (_binding != null) { - // Get current status to ensure we have the latest connection info - val currentStatus = viewModel.connectionStatus.value - if (currentStatus.state == ConnectionState.CONNECTED && currentStatus.connectedSince != null) { - val currentDuration = currentStatus.getConnectionDuration() - if (currentDuration != null) { - binding.tvConnectionDuration.text = currentDuration - } - // Schedule next update - durationUpdateHandler.postDelayed(this, 1000) - } - } - } - } - // Start the updates - durationUpdateHandler.post(durationUpdateRunnable!!) - } - - private fun stopDurationUpdates() { - durationUpdateRunnable?.let { runnable -> - durationUpdateHandler.removeCallbacks(runnable) - } - durationUpdateRunnable = null - } - - private fun loadCachedIpInfo() { - // Load cached IP info without triggering network requests - val cachedLocation = IpLocationService.getCachedIpLocation() - if (cachedLocation != null && _binding != null) { - binding.tvExternalIp.text = cachedLocation.ip - binding.tvCountryName.text = cachedLocation.country - binding.tvCountryFlag.text = cachedLocation.flag - binding.btnRefreshIp.isEnabled = true - } else if (_binding != null) { - // No cache available, show placeholder - binding.tvExternalIp.text = "--" - binding.tvCountryName.text = getString(R.string.unknown_location) - binding.tvCountryFlag.text = "🌍" - binding.btnRefreshIp.isEnabled = true - } - } - - private fun refreshIpInfo(retryCount: Int = 0) { - android.util.Log.d("HomeFragment", "refreshIpInfo called, retryCount: $retryCount") - viewLifecycleOwner.lifecycleScope.launch { - try { - // Проверяем что binding еще валидный перед каждым обращением - if (_binding == null) return@launch - - // Показываем состояние загрузки только при первой попытке - if (retryCount == 0) { - binding.tvExternalIp.text = getString(R.string.checking_ip) - binding.tvCountryName.text = "" - binding.tvCountryFlag.text = "🔄" - binding.btnRefreshIp.isEnabled = false - } - - // Пытаемся получить полную информацию - val ipLocation = IpLocationService.getIpLocation() - - // Проверяем binding перед обновлением UI - if (_binding == null) return@launch - - if (ipLocation != null) { - binding.tvExternalIp.text = ipLocation.ip - binding.tvCountryName.text = ipLocation.country - binding.tvCountryFlag.text = ipLocation.flag - } else { - // Fallback: получаем только IP - val simpleIp = IpLocationService.getSimpleIp() - - // Снова проверяем binding - if (_binding == null) return@launch - - if (simpleIp != null) { - binding.tvExternalIp.text = simpleIp - binding.tvCountryName.text = getString(R.string.unknown_location) - binding.tvCountryFlag.text = "🌍" - } else if (retryCount < 2) { - // Повторяем через 2 секунды максимум 2 раза - kotlinx.coroutines.delay(2000) - refreshIpInfo(retryCount + 1) - return@launch - } else { - // Полная неудача после повторов - binding.tvExternalIp.text = getString(R.string.unable_to_fetch_ip) - binding.tvCountryName.text = getString(R.string.check_network_connection) - binding.tvCountryFlag.text = "❌" - } - } - } catch (e: Exception) { - // Проверяем binding перед обработкой ошибки - if (_binding == null) return@launch - - if (retryCount < 2) { - // Повторяем при ошибке - kotlinx.coroutines.delay(2000) - refreshIpInfo(retryCount + 1) - return@launch - } else { - binding.tvExternalIp.text = getString(R.string.network_error) - binding.tvCountryName.text = getString(R.string.network_error) - binding.tvCountryFlag.text = "❌" - } - } finally { - // Финальная проверка binding - if (_binding != null) { - binding.btnRefreshIp.isEnabled = true - } - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - // Cancel any pending callbacks and animations before destroying the view - blinkingHandler.removeCallbacksAndMessages(null) - blinkAnimator?.cancel() - blinkAnimator = null - - // Stop duration updates - stopDurationUpdates() - durationUpdateHandler.removeCallbacksAndMessages(null) - - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModel.kt b/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModel.kt deleted file mode 100644 index be0d420..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModel.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.example.sshproxy.ui.home - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.sshproxy.SshProxyService -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.ConnectionStatus -import com.example.sshproxy.data.ConnectionState -import com.example.sshproxy.network.ConnectionQuality -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -class HomeViewModel( - private val serverRepository: ServerRepository, - private val preferencesManager: PreferencesManager -) : ViewModel() { - - private val _servers = MutableStateFlow>(emptyList()) - val servers: StateFlow> = _servers.asStateFlow() - - private val _selectedServer = MutableStateFlow(null) - val selectedServer: StateFlow = _selectedServer.asStateFlow() - - val isRunning: StateFlow = SshProxyService.isRunning - - // Combine connection state with current server for full status - private val _connectionStatus = MutableStateFlow(ConnectionStatus()) - val connectionStatus: StateFlow = _connectionStatus.asStateFlow() - - init { - viewModelScope.launch { - serverRepository.getAllServers().collect { serverList -> - _servers.value = serverList - val activeServerId = preferencesManager.getActiveServerId() - val currentSelection = _selectedServer.value - - if (currentSelection == null || serverList.find { it.id == currentSelection.id } == null) { - _selectedServer.value = serverList.find { it.id == activeServerId } ?: serverList.firstOrNull() - } - } - } - - // Monitor connection state changes - viewModelScope.launch { - combine( - SshProxyService.connectionState, - SshProxyService.connectionStartTime, - _selectedServer - ) { connectionState, connectedSince, server -> - val newState = when (connectionState) { - SshProxyService.ConnectionState.DISCONNECTED -> ConnectionState.DISCONNECTED - SshProxyService.ConnectionState.CONNECTING -> ConnectionState.CONNECTING - SshProxyService.ConnectionState.CONNECTED -> ConnectionState.CONNECTED - SshProxyService.ConnectionState.DISCONNECTING -> ConnectionState.DISCONNECTING - } - - _connectionStatus.value = _connectionStatus.value.copy( - state = newState, - server = server, - connectedSince = connectedSince - ) - }.collect { } - } - - // Monitor ping data - viewModelScope.launch { - SshProxyService.currentPingMonitor.collect { pingMonitor -> - if (pingMonitor != null) { - // Collect ping results - launch { - pingMonitor.latestPing.collect { pingResult -> - val serverStats = pingMonitor.serverStats.value - val quality = pingMonitor.getConnectionQuality() - updatePingStats(pingResult, serverStats, quality) - } - } - } - } - } - } - - fun selectServer(server: Server) { - _selectedServer.value = server - preferencesManager.setActiveServerId(server.id) - } - - fun updatePingStats(pingResult: com.example.sshproxy.network.PingResult?, serverStats: com.example.sshproxy.network.ServerStats?, quality: ConnectionQuality) { - _connectionStatus.value = _connectionStatus.value.copy( - latestPing = pingResult, - serverStats = serverStats, - connectionQuality = quality - ) - } - - fun updateConnectionError(error: String) { - _connectionStatus.value = _connectionStatus.value.copy( - state = ConnectionState.ERROR, - errorMessage = error - ) - } - - fun updateReconnectionStatus(isReconnecting: Boolean, attempt: Int, maxAttempts: Int) { - _connectionStatus.value = _connectionStatus.value.copy( - isReconnecting = isReconnecting, - reconnectionAttempt = attempt, - maxReconnectionAttempts = maxAttempts, - state = if (isReconnecting) ConnectionState.RECONNECTING else _connectionStatus.value.state - ) - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModelFactory.kt b/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModelFactory.kt deleted file mode 100644 index 56b70e1..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModelFactory.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.sshproxy.ui.home - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository - -class HomeViewModelFactory( - private val repository: ServerRepository, - private val preferencesManager: PreferencesManager -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(HomeViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return HomeViewModel(repository, preferencesManager) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/home/TestConnectionBottomSheet.kt b/app/src/main/java/com/example/sshproxy/ui/home/TestConnectionBottomSheet.kt deleted file mode 100644 index bac518e..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/home/TestConnectionBottomSheet.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.example.sshproxy.ui.home - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.example.sshproxy.R -import com.example.sshproxy.SshProxyService -import com.example.sshproxy.data.ConnectionState -import com.example.sshproxy.network.ConnectionQuality -import com.example.sshproxy.network.HttpLatencyTester -import kotlinx.coroutines.launch - -class TestConnectionBottomSheet(private val viewModel: HomeViewModel) : BottomSheetDialogFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.bottom_sheet_test_connection, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val tvTestStatus = view.findViewById(R.id.tvTestStatus) - val progressBar = view.findViewById(R.id.progressBar) - val layoutResults = view.findViewById(R.id.layoutResults) - val layoutTestResults = view.findViewById(R.id.layoutTestResults) - val viewOverallIndicator = view.findViewById(R.id.viewOverallIndicator) - val tvOverallResult = view.findViewById(R.id.tvOverallResult) - val btnClose = view.findViewById(R.id.btnClose) - - btnClose.setOnClickListener { dismiss() } - - lifecycleScope.launch { - try { - // Create HTTP latency tester - val selectedServer = viewModel.selectedServer.value - val isVpnActive = SshProxyService.connectionState.value == SshProxyService.ConnectionState.CONNECTED - val latencyTester = if (isVpnActive) { - HttpLatencyTester( - proxyHost = "127.0.0.1", - proxyPort = selectedServer?.httpProxyPort ?: 8080, - timeoutMs = 10000 - ) - } else { - HttpLatencyTester( - proxyHost = null, - proxyPort = null, - timeoutMs = 10000 - ) - } - - // Start testing - tvTestStatus.text = getString(R.string.test_connection_checking) - progressBar.progress = 10 - - val result = latencyTester.performSingleTest() - - // Update progress - progressBar.progress = 100 - tvTestStatus.text = getString(R.string.test_connection_completed) - - // Show results after a short delay - kotlinx.coroutines.delay(500) - layoutResults.visibility = View.VISIBLE - - // Clear any existing results - layoutTestResults.removeAllViews() - - // Add individual test results - result.individualResults.forEach { testResult -> - val resultView = createTestResultView(testResult) - layoutTestResults.addView(resultView) - } - - // Determine overall quality - val overallQuality = when { - result.successRate < 50f -> ConnectionQuality.POOR - result.averageLatencyMs > 1500 -> ConnectionQuality.POOR - result.averageLatencyMs > 1000 -> ConnectionQuality.FAIR - result.averageLatencyMs > 600 -> ConnectionQuality.GOOD - else -> ConnectionQuality.EXCELLENT - } - - // Update overall result - viewOverallIndicator.setBackgroundColor(overallQuality.color) - tvOverallResult.text = "${overallQuality.getDisplayName(requireContext())} (${result.averageLatencyMs}ms)" - tvOverallResult.setTextColor(overallQuality.color) - - } catch (e: Exception) { - progressBar.progress = 100 - tvTestStatus.text = getString(R.string.test_connection_error) - layoutResults.visibility = View.VISIBLE - - val errorView = TextView(requireContext()).apply { - text = "Ошибка: ${e.message ?: "Неизвестная ошибка"}" - textSize = 14f - setTextColor(ContextCompat.getColor(requireContext(), R.color.error)) - setPadding(16, 8, 16, 8) - } - layoutTestResults.addView(errorView) - - viewOverallIndicator.setBackgroundColor(ConnectionQuality.POOR.color) - tvOverallResult.text = "Ошибка теста" - tvOverallResult.setTextColor(ConnectionQuality.POOR.color) - } - } - } - - private fun createTestResultView(testResult: com.example.sshproxy.network.HttpLatencyResult): View { - val resultLayout = LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(0, 8, 0, 8) - } - gravity = android.view.Gravity.CENTER_VERTICAL - setPadding(0, 8, 0, 8) - } - - // Status indicator - val indicator = View(requireContext()).apply { - layoutParams = LinearLayout.LayoutParams(8, 8).apply { - setMargins(0, 0, 12, 0) - } - background = ContextCompat.getDrawable(requireContext(), R.drawable.circle_shape) - setBackgroundColor( - if (testResult.isSuccessful) { - when { - testResult.latencyMs > 1500 -> ContextCompat.getColor(requireContext(), R.color.error) - testResult.latencyMs > 1000 -> ContextCompat.getColor(requireContext(), R.color.warning) - testResult.latencyMs > 600 -> ContextCompat.getColor(requireContext(), R.color.good) - else -> ContextCompat.getColor(requireContext(), R.color.good) - } - } else { - ContextCompat.getColor(requireContext(), R.color.error) - } - ) - } - - // URL and result text - val textView = TextView(requireContext()).apply { - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - textSize = 14f - val hostname = testResult.url.replace("https://", "").replace("http://", "").split("/")[0] - text = if (testResult.isSuccessful) { - "$hostname: ${testResult.latencyMs}ms" - } else { - "$hostname: ${testResult.errorMessage ?: "Ошибка"}" - } - setTextColor( - if (testResult.isSuccessful) { - // Get color from current theme - val typedValue = android.util.TypedValue() - requireContext().theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true) - typedValue.data - } else { - ContextCompat.getColor(requireContext(), R.color.error) - } - ) - } - - resultLayout.addView(indicator) - resultLayout.addView(textView) - - return resultLayout - } - - companion object { - const val TAG = "TestConnectionBottomSheet" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsFragment.kt b/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsFragment.kt deleted file mode 100644 index c778ae3..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsFragment.kt +++ /dev/null @@ -1,382 +0,0 @@ -package com.example.sshproxy.ui.instructions - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.databinding.FragmentInstructionsBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.InetAddress -import java.net.Socket -import java.net.URL - -class InstructionsFragment : Fragment() { - private var _binding: FragmentInstructionsBinding? = null - private val binding get() = _binding!! - - private val viewModel: InstructionsViewModel by viewModels { - InstructionsViewModelFactory( - KeyRepository(requireContext()), - ServerRepository(requireContext()), - PreferencesManager(requireContext()) - ) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentInstructionsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - observeViewModel() - - binding.btnSelectServer.setOnClickListener { - showServerSelector() - } - - binding.btnSelectKey.setOnClickListener { - showKeySelector() - } - - binding.btnCopyInstructions.setOnClickListener { - copyInstructions() - } - - binding.btnCopyQuick.setOnClickListener { - copyQuickCommand() - } - - binding.btnTestNetwork.setOnClickListener { - runNetworkTest() - } - } - - private fun observeViewModel() { - viewLifecycleOwner.lifecycleScope.launch { - combine(viewModel.selectedKey, viewModel.selectedServer) { key, server -> - Pair(key, server) - }.collect { (key, server) -> - binding.tvSelectedKey.text = key?.name ?: "No key selected" - binding.tvSelectedServer.text = server?.name ?: "No server selected" - - if (key != null) { - val username = server?.username ?: "sshproxy" - binding.tvInstructions.text = generateInstructions(key.publicKey, username) - binding.tvQuickCommand.text = generateQuickCommand(key.publicKey, username) - } else { - binding.tvInstructions.text = "Please select or generate an SSH key first" - binding.tvQuickCommand.text = "No key selected" - } - } - } - } - - private fun generateInstructions(publicKey: String, username: String): String { - return """ -# SSH Server Setup Instructions - -## 1. Create restricted user for SSH tunneling: -sudo adduser --disabled-password --gecos "" --home /home/$username $username -sudo usermod -s /usr/sbin/nologin $username - -## 2. Setup SSH key authentication: -sudo install -d -m 700 -o $username -g $username /home/$username/.ssh -sudo bash -c 'echo "restrict,port-forwarding $publicKey" > /home/$username/.ssh/authorized_keys' -sudo chown $username:$username /home/$username/.ssh/authorized_keys -sudo chmod 600 /home/$username/.ssh/authorized_keys - -## 3. Configure SSH restrictions for the user: -sudo bash -c 'cat >/etc/ssh/sshd_config.d/$username.conf </dev/null 2>&1; then - sudo apt-get update && sudo apt-get install -y tinyproxy -elif command -v yum >/dev/null 2>&1; then - sudo yum install -y tinyproxy -elif command -v dnf >/dev/null 2>&1; then - sudo dnf install -y tinyproxy -elif command -v pacman >/dev/null 2>&1; then - sudo pacman -S --noconfirm tinyproxy -fi - -# Configure Tinyproxy to listen on port 8118 -sudo sed -i 's/^Port.*/Port 8118/' /etc/tinyproxy/tinyproxy.conf -sudo sed -i 's/^Listen.*/Listen 127.0.0.1/' /etc/tinyproxy/tinyproxy.conf -# Allow localhost connections -sudo sed -i 's/^#Allow 127.0.0.1/Allow 127.0.0.1/' /etc/tinyproxy/tinyproxy.conf -sudo systemctl restart tinyproxy -sudo systemctl enable tinyproxy - -## Notes: -- The user '$username' is restricted to port forwarding only -- No shell access, no TTY, no X11 forwarding -- Tinyproxy will run on port 8118 (localhost only) -- Test connection: ssh -N -L 8080:127.0.0.1:8118 $username@your-server - """.trimIndent() - } - - private fun generateQuickCommand(publicKey: String, username: String): String { - return """#!/bin/bash -# Quick setup script - review before running! -USER="$username" -KEY="restrict,port-forwarding $publicKey" - -# Detect package manager -if command -v apt-get >/dev/null 2>&1; then - PKG_MGR="apt-get" - PKG_UPDATE="apt-get update" - PKG_INSTALL="apt-get install -y" -elif command -v yum >/dev/null 2>&1; then - PKG_MGR="yum" - PKG_UPDATE="yum check-update || true" - PKG_INSTALL="yum install -y" -elif command -v dnf >/dev/null 2>&1; then - PKG_MGR="dnf" - PKG_UPDATE="dnf check-update || true" - PKG_INSTALL="dnf install -y" -elif command -v pacman >/dev/null 2>&1; then - PKG_MGR="pacman" - PKG_UPDATE="pacman -Sy" - PKG_INSTALL="pacman -S --noconfirm" -else - echo "Error: No supported package manager found!" - exit 1 -fi - -echo "Using package manager: ${'$'}PKG_MGR" - -# Auto-detect SSH service name -if systemctl is-active --quiet ssh; then - SSH_SERVICE="ssh" -elif systemctl is-active --quiet sshd; then - SSH_SERVICE="sshd" -else - SSH_SERVICE="ssh" # Default to ssh for Ubuntu/Debian -fi - -echo "SSH service detected: ${'$'}SSH_SERVICE" - -# Create user and setup SSH -sudo adduser --disabled-password --gecos "" --home /home/${'$'}USER ${'$'}USER && \ -sudo usermod -s /usr/sbin/nologin ${'$'}USER && \ -sudo install -d -m 700 -o ${'$'}USER -g ${'$'}USER /home/${'$'}USER/.ssh && \ -sudo bash -c "echo '${'$'}KEY' > /home/${'$'}USER/.ssh/authorized_keys" && \ -sudo chown ${'$'}USER:${'$'}USER /home/${'$'}USER/.ssh/authorized_keys && \ -sudo chmod 600 /home/${'$'}USER/.ssh/authorized_keys && \ -sudo bash -c "cat >/etc/ssh/sshd_config.d/${'$'}USER.conf < "${server.name} (${server.username}@${server.host})" }.toTypedArray() - val currentId = viewModel.selectedServer.value?.id - val currentIndex = servers.indexOfFirst { server -> server.id == currentId }.takeIf { index -> index >= 0 } ?: 0 - - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Select Server for Instructions") - .setSingleChoiceItems(serverNames, currentIndex) { dialog, which -> - viewModel.selectServer(servers[which]) - dialog.dismiss() - } - .show() - } - - private fun showKeySelector() { - val keys = viewModel.keys.value - if (keys.isEmpty()) { - Toast.makeText(context, "No SSH keys configured", Toast.LENGTH_SHORT).show() - return - } - - val keyNames = keys.map { key -> key.name }.toTypedArray() - val currentId = viewModel.selectedKey.value?.id - val currentIndex = keys.indexOfFirst { key -> key.id == currentId }.takeIf { index -> index >= 0 } ?: 0 - - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Select SSH Key") - .setSingleChoiceItems(keyNames, currentIndex) { dialog, which -> - viewModel.selectKey(keys[which]) - dialog.dismiss() - } - .show() - } - - private fun copyInstructions() { - val text = binding.tvInstructions.text.toString() - if (text.isNotBlank() && !text.contains("Please select")) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Server Instructions", text) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Instructions copied", Toast.LENGTH_SHORT).show() - } - } - - private fun copyQuickCommand() { - val text = binding.tvQuickCommand.text.toString() - if (text.isNotBlank() && !text.contains("No key selected")) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Quick Setup Script", text) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Quick command copied", Toast.LENGTH_SHORT).show() - } - } - - private fun runNetworkTest() { - val server = viewModel.selectedServer.value - if (server == null) { - Toast.makeText(context, "Please select a server first", Toast.LENGTH_SHORT).show() - return - } - - Toast.makeText(context, "Running network test...", Toast.LENGTH_SHORT).show() - - viewLifecycleOwner.lifecycleScope.launch { - val results = withContext(Dispatchers.IO) { - val testResults = mutableListOf() - - // Тест 1: DNS разрешение - try { - val address = InetAddress.getByName(server.host) - testResults.add("✓ DNS Resolution: ${server.host} → ${address.hostAddress}") - } catch (e: Exception) { - testResults.add("✗ DNS Resolution: Failed - ${e.message}") - } - - // Тест 2: HTTP подключение (проверяем общую сетевую доступность) - try { - val url = URL("https://www.google.com") - val connection = url.openConnection() - connection.connectTimeout = 5000 - connection.readTimeout = 5000 - connection.connect() - testResults.add("✓ Internet Access: Working") - } catch (e: Exception) { - testResults.add("✗ Internet Access: Failed - ${e.message}") - } - - // Тест 3: Loopback (базовая функциональность сокетов) - try { - val localSocket = Socket() - localSocket.connect(java.net.InetSocketAddress("127.0.0.1", 1), 1000) - localSocket.close() - testResults.add("✓ Local Sockets: Working") - } catch (e: Exception) { - testResults.add("✗ Local Sockets: ${e.message}") - } - - // Тест 4: SSH подключение к серверу - try { - val sshSocket = Socket() - sshSocket.connect(java.net.InetSocketAddress(server.host, server.port), 10000) - sshSocket.close() - testResults.add("✓ SSH Server (${server.host}:${server.port}): Reachable") - } catch (e: Exception) { - testResults.add("✗ SSH Server (${server.host}:${server.port}): ${e.message}") - } - - // Тест 5: Информация о процессе - testResults.add("") - testResults.add("=== Debug Information ===") - testResults.add("Process UID: ${android.os.Process.myUid()}") - testResults.add("Process PID: ${android.os.Process.myPid()}") - testResults.add("Thread: ${Thread.currentThread().name}") - - testResults - } - - // Показываем результаты в диалоге - val message = results.joinToString("\n") - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Network Test Results") - .setMessage(message) - .setPositiveButton("OK", null) - .setNegativeButton("Copy Results") { _, _ -> - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Network Test Results", message) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Results copied to clipboard", Toast.LENGTH_SHORT).show() - } - .show() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModel.kt b/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModel.kt deleted file mode 100644 index 4cb0f0c..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModel.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.sshproxy.ui.instructions - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.sshproxy.data.* -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch - -class InstructionsViewModel( - private val keyRepository: KeyRepository, - private val serverRepository: ServerRepository, - private val preferencesManager: PreferencesManager -) : ViewModel() { - - private val _keys = MutableStateFlow>(emptyList()) - val keys: StateFlow> = _keys.asStateFlow() - - private val _servers = MutableStateFlow>(emptyList()) - val servers: StateFlow> = _servers.asStateFlow() - - private val _selectedKey = MutableStateFlow(null) - val selectedKey: StateFlow = _selectedKey.asStateFlow() - - private val _selectedServer = MutableStateFlow(null) - val selectedServer: StateFlow = _selectedServer.asStateFlow() - - init { - viewModelScope.launch { - keyRepository.getAllKeys().collect { keyList -> - _keys.value = keyList - val activeKeyId = preferencesManager.getActiveKeyId() - // If no key is selected, or the selected one was deleted, select a new one. - if (_selectedKey.value == null || keyList.find { it.id == _selectedKey.value?.id } == null) { - val newKeyToSelect = keyList.find { it.id == activeKeyId } ?: keyList.firstOrNull() - _selectedKey.value = newKeyToSelect - // Also update the preference if we made a new selection - newKeyToSelect?.let { preferencesManager.setActiveKeyId(it.id) } - } - } - } - - viewModelScope.launch { - serverRepository.getAllServers().collect { serverList -> - _servers.value = serverList - val activeServerId = preferencesManager.getActiveServerId() - // If no server is selected, or the selected one was deleted, select a new one. - if (_selectedServer.value == null || serverList.find { it.id == _selectedServer.value?.id } == null) { - val newServerToSelect = serverList.find { it.id == activeServerId } ?: serverList.firstOrNull() - _selectedServer.value = newServerToSelect - // Also update the preference if we made a new selection - newServerToSelect?.let { preferencesManager.setActiveServerId(it.id) } - } - } - } - } - - fun selectKey(key: SshKey) { - _selectedKey.value = key - preferencesManager.setActiveKeyId(key.id) - } - - fun selectServer(server: Server) { - _selectedServer.value = server - preferencesManager.setActiveServerId(server.id) - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModelFactory.kt b/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModelFactory.kt deleted file mode 100644 index 962eb47..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModelFactory.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.sshproxy.ui.instructions - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository - -class InstructionsViewModelFactory( - private val keyRepository: KeyRepository, - private val serverRepository: ServerRepository, - private val preferencesManager: PreferencesManager -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(InstructionsViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return InstructionsViewModel(keyRepository, serverRepository, preferencesManager) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/keys/AddKeyDialog.kt b/app/src/main/java/com/example/sshproxy/ui/keys/AddKeyDialog.kt deleted file mode 100644 index 601292b..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/keys/AddKeyDialog.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.sshproxy.ui.keys - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.example.sshproxy.R -import com.example.sshproxy.databinding.DialogAddKeyBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class AddKeyDialog( - private val onSave: (String) -> Unit -) : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = DialogAddKeyBinding.inflate(layoutInflater) - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.generate_new_key)) - .setView(binding.root) - .setPositiveButton(getString(com.example.sshproxy.R.string.generate)) { _, _ -> - val name = binding.etKeyName.text.toString().trim() - if (name.isNotEmpty()) { - onSave(name) - } - } - .setNegativeButton(getString(com.example.sshproxy.R.string.cancel), null) - .create() - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/keys/KeysAdapter.kt b/app/src/main/java/com/example/sshproxy/ui/keys/KeysAdapter.kt deleted file mode 100644 index 0eb6584..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/keys/KeysAdapter.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.sshproxy.ui.keys - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.example.sshproxy.data.SshKey -import com.example.sshproxy.databinding.ItemKeyBinding - -class KeysAdapter( - private val onKeyClick: (SshKey) -> Unit, - private val onKeyDelete: (SshKey) -> Unit, - private val onKeyCopy: (SshKey) -> Unit -) : ListAdapter(KeyDiffCallback()) { - - private var activeKeyId: String? = null - - fun setActiveKeyId(id: String?) { - activeKeyId = id - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KeyViewHolder { - val binding = ItemKeyBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return KeyViewHolder(binding) - } - - override fun onBindViewHolder(holder: KeyViewHolder, position: Int) { - val key = getItem(position) - holder.bind(key, key.id == activeKeyId, onKeyClick, onKeyDelete, onKeyCopy) - } - - class KeyViewHolder(private val binding: ItemKeyBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(key: SshKey, isActive: Boolean, onClick: (SshKey) -> Unit, onDelete: (SshKey) -> Unit, onCopy: (SshKey) -> Unit) { - binding.tvKeyName.text = key.name - binding.tvKeyFingerprint.text = key.fingerprint - binding.radioActive.isChecked = isActive - - binding.root.setOnClickListener { onClick(key) } - binding.btnDelete.setOnClickListener { onDelete(key) } - binding.btnCopyKey.setOnClickListener { onCopy(key) } - } - } - - class KeyDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SshKey, newItem: SshKey): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: SshKey, newItem: SshKey): Boolean = oldItem == newItem - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/keys/KeysFragment.kt b/app/src/main/java/com/example/sshproxy/ui/keys/KeysFragment.kt deleted file mode 100644 index 70d5ad0..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/keys/KeysFragment.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.example.sshproxy.ui.keys - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.databinding.FragmentKeysBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class KeysFragment : Fragment() { - private var _binding: FragmentKeysBinding? = null - private val binding get() = _binding!! - - private lateinit var keyRepository: KeyRepository - private lateinit var preferencesManager: PreferencesManager - private lateinit var adapter: KeysAdapter - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentKeysBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - keyRepository = KeyRepository(requireContext()) - preferencesManager = PreferencesManager(requireContext()) - setupRecyclerView() - - binding.fabAddKey.setOnClickListener { - AddKeyDialog { name -> - lifecycleScope.launch { - keyRepository.generateKeyPair(name) - // After generating, find the new key and set it as active - keyRepository.getAllKeys().collectLatest { keys -> - val newKey = keys.find { it.name == name } - if (newKey != null) { - preferencesManager.setActiveKeyId(newKey.id) - adapter.setActiveKeyId(newKey.id) - } - } - Toast.makeText(context, getString(com.example.sshproxy.R.string.key_generated_and_set_as_active, name), Toast.LENGTH_SHORT).show() - } - }.show(parentFragmentManager, "add_key") - } - - observeKeys() - } - - private fun setupRecyclerView() { - adapter = KeysAdapter( - onKeyClick = { key -> - preferencesManager.setActiveKeyId(key.id) - adapter.setActiveKeyId(key.id) - }, - onKeyDelete = { key -> - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.delete_key)) - .setMessage(getString(com.example.sshproxy.R.string.delete_key_confirmation_with_name, key.name)) - .setPositiveButton(getString(com.example.sshproxy.R.string.delete)) { _, _ -> - lifecycleScope.launch { - keyRepository.deleteKey(key.id) - } - } - .setNegativeButton(getString(com.example.sshproxy.R.string.cancel), null) - .show() - }, - onKeyCopy = { key -> - copyKeyToClipboard(key.publicKey) - } - ) - - binding.recyclerView.layoutManager = LinearLayoutManager(context) - binding.recyclerView.adapter = adapter - } - - private fun observeKeys() { - lifecycleScope.launch { - keyRepository.getAllKeys().collectLatest { keys -> - adapter.submitList(keys) - - var activeKeyId = preferencesManager.getActiveKeyId() - // If no key is active, but keys exist, make the first one active. - if (activeKeyId == null && keys.isNotEmpty()) { - activeKeyId = keys.first().id - preferencesManager.setActiveKeyId(activeKeyId) - } - adapter.setActiveKeyId(activeKeyId) - - binding.emptyView.visibility = if (keys.isEmpty()) View.VISIBLE else View.GONE - binding.recyclerView.visibility = if (keys.isEmpty()) View.GONE else View.VISIBLE - } - } - } - - private fun copyKeyToClipboard(publicKey: String) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(getString(com.example.sshproxy.R.string.public_key), publicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, getString(com.example.sshproxy.R.string.public_key_copied), Toast.LENGTH_SHORT).show() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/log/LogFragment.kt b/app/src/main/java/com/example/sshproxy/ui/log/LogFragment.kt deleted file mode 100644 index 2949095..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/log/LogFragment.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.sshproxy.ui.log - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import com.example.sshproxy.AppLog -import com.example.sshproxy.databinding.FragmentLogBinding - -class LogFragment : Fragment() { - private var _binding: FragmentLogBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentLogBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - AppLog.logMessages.observe(viewLifecycleOwner) { logs -> - binding.tvLog.text = logs.joinToString("\n") - // Auto-scroll to bottom - binding.scrollView.post { - binding.scrollView.fullScroll(View.FOCUS_DOWN) - } - } - - binding.fabCopy.setOnClickListener { - copyLogToClipboard() - } - } - - private fun copyLogToClipboard() { - val logText = binding.tvLog.text.toString() - if (logText.isNotBlank()) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("App Log", logText) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Log copied", Toast.LENGTH_SHORT).show() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/AddServerDialog.kt b/app/src/main/java/com/example/sshproxy/ui/servers/AddServerDialog.kt deleted file mode 100644 index c1d4058..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/AddServerDialog.kt +++ /dev/null @@ -1,260 +0,0 @@ -package com.example.sshproxy.ui.servers - -import android.app.Dialog -import android.os.Bundle -import android.view.View -import android.widget.Toast -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.SshKey -import com.example.sshproxy.databinding.DialogAddServerBinding -import com.example.sshproxy.network.SshAlgorithmManager -import android.widget.ArrayAdapter -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch - -class AddServerDialog( - private val server: Server? = null, - private val onSave: (Server) -> Unit -) : DialogFragment() { - - private var selectedKey: SshKey? = null - private var keys: List = emptyList() - private lateinit var keyRepository: KeyRepository - private lateinit var preferencesManager: PreferencesManager - private lateinit var binding: DialogAddServerBinding - private val algorithmManager = SshAlgorithmManager() - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogAddServerBinding.inflate(layoutInflater) - - // Initialize repositories - keyRepository = KeyRepository(requireContext()) - preferencesManager = PreferencesManager(requireContext()) - - // Setup SSH algorithm dropdowns - setupAlgorithmDropdowns() - - // Setup initial values - server?.let { - binding.etHost.setText(it.host) - binding.etServerName.setText(it.name) - binding.etUsername.setText(it.username) - binding.etPort.setText(it.port.toString()) - binding.etHttpProxyPort.setText(it.httpProxyPort.toString()) - - // Set algorithm values - binding.etCipher.setText(it.preferredCipher ?: "", false) - binding.etKex.setText(it.preferredKex ?: "", false) - binding.etMac.setText(it.preferredMac ?: "", false) - } ?: run { - binding.etUsername.setText("user") - binding.etPort.setText("22") - binding.etHttpProxyPort.setText("8080") - } - - // Load SSH keys - loadSshKeys(binding) - - // SSH key selection - binding.btnSelectKey.setOnClickListener { - showSshKeySelectionDialog() - } - - // Auto-fill name from host with URL cleanup - binding.etHost.doAfterTextChanged { text -> - if (!text.isNullOrEmpty()) { - val cleanHost = cleanUrl(text.toString()) - - // Update host field if URL was cleaned - if (cleanHost != text.toString()) { - binding.etHost.setText(cleanHost) - binding.etHost.setSelection(cleanHost.length) // Move cursor to end - } - - // Auto-fill name only if it's empty - if (binding.etServerName.text.isNullOrEmpty()) { - binding.etServerName.setText(cleanHost) - } - } - } - - // Toggle advanced options - binding.btnToggleAdvanced.setOnClickListener { - val isVisible = binding.advancedOptionsLayout.visibility == View.VISIBLE - binding.advancedOptionsLayout.visibility = if (isVisible) View.GONE else View.VISIBLE - binding.btnToggleAdvanced.setIconResource( - if (isVisible) com.example.sshproxy.R.drawable.ic_expand_more - else com.example.sshproxy.R.drawable.ic_expand_less - ) - } - - val title = if (server == null) getString(com.example.sshproxy.R.string.add_server) else getString(com.example.sshproxy.R.string.edit_server) - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(title) - .setView(binding.root) - .setPositiveButton(getString(com.example.sshproxy.R.string.save)) { _, _ -> - val name = binding.etServerName.text.toString().trim() - val host = binding.etHost.text.toString().trim() - val port = binding.etPort.text.toString().toIntOrNull() ?: 22 - val httpProxyPort = binding.etHttpProxyPort.text.toString().toIntOrNull() ?: 8080 - val username = binding.etUsername.text.toString().trim() - - // Get algorithm selections (empty string means auto-detect) - val cipher = binding.etCipher.text.toString().takeIf { it.isNotEmpty() } - val kex = binding.etKex.text.toString().takeIf { it.isNotEmpty() } - val mac = binding.etMac.text.toString().takeIf { it.isNotEmpty() } - - if (name.isNotEmpty() && host.isNotEmpty() && username.isNotEmpty()) { - val newServer = server?.copy( - name = name, - host = host, - port = port, - httpProxyPort = httpProxyPort, - username = username, - sshKeyId = selectedKey?.id, - preferredCipher = cipher, - preferredKex = kex, - preferredMac = mac - ) ?: Server( - name = name, - host = host, - port = port, - httpProxyPort = httpProxyPort, - username = username, - sshKeyId = selectedKey?.id, - preferredCipher = cipher, - preferredKex = kex, - preferredMac = mac - ) - onSave(newServer) - } - } - .setNegativeButton(getString(com.example.sshproxy.R.string.cancel), null) - .create() - } - - private fun loadSshKeys(binding: DialogAddServerBinding) { - lifecycleScope.launch { - keyRepository.getAllKeys().collect { keyList -> - keys = keyList - - // Set initial selection based on server or use active key - if (server?.sshKeyId != null) { - selectedKey = keys.find { it.id == server.sshKeyId } - } else { - val activeKeyId = preferencesManager.getActiveKeyId() - selectedKey = keys.find { it.id == activeKeyId } - } - - updateKeyDisplay(binding) - } - } - } - - private fun showSshKeySelectionDialog() { - val keyNames = arrayOf("Use active key") + keys.map { it.name }.toTypedArray() - val currentSelection = if (selectedKey != null) { - keys.indexOfFirst { it.id == selectedKey!!.id } + 1 - } else { - 0 - } - - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Select SSH Key") - .setSingleChoiceItems(keyNames, currentSelection) { dialog, which -> - selectedKey = if (which == 0) null else keys[which - 1] - dialog.dismiss() - updateKeyDisplay(binding) - } - .setNegativeButton("Cancel", null) - .show() - } - - private fun updateKeyDisplay(binding: DialogAddServerBinding) { - binding.tvSelectedKey.text = if (selectedKey != null) { - selectedKey!!.name - } else { - "Use active key" - } - } - - private fun cleanUrl(input: String): String { - var cleaned = input.trim() - - // Remove common URL prefixes - val prefixes = listOf("http://", "https://", "ftp://", "ftps://") - for (prefix in prefixes) { - if (cleaned.startsWith(prefix, ignoreCase = true)) { - cleaned = cleaned.substring(prefix.length) - break - } - } - - // Remove trailing slashes and paths - val slashIndex = cleaned.indexOf('/') - if (slashIndex != -1) { - cleaned = cleaned.substring(0, slashIndex) - } - - // Remove port from display (user can set it separately) - val colonIndex = cleaned.lastIndexOf(':') - if (colonIndex != -1) { - // Check if what follows the colon is a number (port) - val afterColon = cleaned.substring(colonIndex + 1) - if (afterColon.toIntOrNull() != null) { - cleaned = cleaned.substring(0, colonIndex) - } - } - - return cleaned - } - - private fun setupAlgorithmDropdowns() { - // Get supported algorithms - val supportedAlgorithms = algorithmManager.getSupportedAlgorithms() - val displayNames = algorithmManager.getAlgorithmDisplayNames() - - // Setup cipher dropdown - val cipherList = supportedAlgorithms.cipher?.split(",") ?: emptyList() - val cipherDisplayList = cipherList.map { algorithm -> - displayNames[algorithm] ?: algorithm - } - val cipherAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, cipherDisplayList) - binding.etCipher.setAdapter(cipherAdapter) - - // Setup KEX dropdown - val kexList = supportedAlgorithms.kex?.split(",") ?: emptyList() - val kexDisplayList = kexList.map { algorithm -> - displayNames[algorithm] ?: algorithm - } - val kexAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, kexDisplayList) - binding.etKex.setAdapter(kexAdapter) - - // Setup MAC dropdown - val macList = supportedAlgorithms.mac?.split(",") ?: emptyList() - val macDisplayList = macList.map { algorithm -> - displayNames[algorithm] ?: algorithm - } - val macAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, macDisplayList) - binding.etMac.setAdapter(macAdapter) - - // Set up listeners to store actual algorithm names (not display names) - binding.etCipher.setOnItemClickListener { _, _, position, _ -> - binding.etCipher.setText(cipherList.getOrNull(position) ?: "", false) - } - - binding.etKex.setOnItemClickListener { _, _, position, _ -> - binding.etKex.setText(kexList.getOrNull(position) ?: "", false) - } - - binding.etMac.setOnItemClickListener { _, _, position, _ -> - binding.etMac.setText(macList.getOrNull(position) ?: "", false) - } - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServerTestResult.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServerTestResult.kt deleted file mode 100644 index d2df2b4..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServerTestResult.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.sshproxy.ui.servers - -import com.example.sshproxy.network.ConnectionQuality - -data class ServerTestResult( - val serverId: Long, - val latencyMs: Long = 0, - val quality: ConnectionQuality = ConnectionQuality.UNKNOWN, - val isSuccess: Boolean = false, - val errorMessage: String? = null, - val isLoading: Boolean = false -) { - companion object { - fun loading(serverId: Long) = ServerTestResult(serverId = serverId, isLoading = true) - - fun success(serverId: Long, latencyMs: Long, quality: ConnectionQuality) = - ServerTestResult(serverId = serverId, latencyMs = latencyMs, quality = quality, isSuccess = true) - - fun failure(serverId: Long, errorMessage: String) = - ServerTestResult(serverId = serverId, errorMessage = errorMessage, isSuccess = false) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServerTester.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServerTester.kt deleted file mode 100644 index 52d426a..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServerTester.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.example.sshproxy.ui.servers - -import android.content.Context -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.network.HttpLatencyTester -import com.example.sshproxy.network.ConnectionQuality -import com.example.sshproxy.security.KnownHostsManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.connection.channel.direct.LocalPortForwarder -import net.schmizz.sshj.connection.channel.direct.Parameters -import net.schmizz.sshj.transport.verification.HostKeyVerifier -import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile -import java.io.File -import java.io.IOException -import java.net.InetAddress -import java.net.ServerSocket - -class ServerTester(private val context: Context) { - - private val serverRepository = ServerRepository(context) - private val keyRepository = KeyRepository(context) - private val preferencesManager = PreferencesManager(context) - - suspend fun test(server: Server): ServerTestResult = withContext(Dispatchers.IO) { - var sshClient: SSHClient? = null - var localPortForwarder: LocalPortForwarder? = null - var serverSocket: ServerSocket? = null - - try { - // 1. Establish SSH connection - sshClient = SSHClient() - sshClient.addHostKeyVerifier(ServerTestHostKeyVerifier(context)) - sshClient.connect(server.host, server.port) - - val keyFile = resolvePrivateKeyFile(server.sshKeyId) - ?: throw IOException("SSH key not found. Please generate one.") - - val keyProvider = PKCS8KeyFile() - keyProvider.init(keyFile) - sshClient.authPublickey(server.username, keyProvider) - - // 2. Setup local port forwarder - serverSocket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) - val localPort = serverSocket.localPort - val params = Parameters("127.0.0.1", localPort, "127.0.0.1", 8118) - localPortForwarder = sshClient.newLocalPortForwarder(params, serverSocket) - - val forwarderJob = GlobalScope.launch(Dispatchers.IO) { - try { - localPortForwarder.listen() - } catch (e: Exception) { - // Ignore - } - } - - // 3. Run HttpLatencyTester - val latencyTester = HttpLatencyTester( - proxyHost = "127.0.0.1", - proxyPort = localPort, - timeoutMs = 5000 - ) - val result = latencyTester.performSingleTest() - - forwarderJob.cancel() - - val quality = when { - result.averageLatencyMs > 2000 -> ConnectionQuality.POOR - result.averageLatencyMs > 1000 -> ConnectionQuality.FAIR - result.averageLatencyMs > 500 -> ConnectionQuality.GOOD - else -> ConnectionQuality.EXCELLENT - } - - ServerTestResult.success(server.id, result.averageLatencyMs, quality) - } catch (e: Exception) { - ServerTestResult.failure(server.id, e.message ?: "Unknown error") - } finally { - localPortForwarder?.close() - serverSocket?.close() - sshClient?.disconnect() - } - } - - private suspend fun resolvePrivateKeyFile(serverSshKeyId: String? = null): File? { - val keyId = serverSshKeyId ?: preferencesManager.getActiveKeyId() - if (keyId != null) { - try { - val privateKey = keyRepository.getPrivateKey(keyId) - if (privateKey != null) { - val tempFile = File.createTempFile("ssh_key_$keyId", ".pem", context.cacheDir) - tempFile.deleteOnExit() - val pemContent = convertPrivateKeyToPem(privateKey) - tempFile.writeText(pemContent) - return tempFile - } else { - return null - } - } catch (e: Exception) { - return null - } - } else { - return null - } - } - - private fun convertPrivateKeyToPem(privateKey: java.security.PrivateKey): String { - val stringWriter = java.io.StringWriter() - org.bouncycastle.util.io.pem.PemWriter(stringWriter).use { pemWriter -> - pemWriter.writeObject(org.bouncycastle.util.io.pem.PemObject("PRIVATE KEY", privateKey.encoded)) - } - return stringWriter.toString() - } -} - -class ServerTestHostKeyVerifier(private val context: Context) : HostKeyVerifier { - private val knownHostsManager = KnownHostsManager(context) - - override fun verify(hostname: String?, port: Int, key: java.security.PublicKey?): Boolean { - if (hostname == null || key == null) { - return false - } - val keyBytes = key.encoded - val keyType = determineKeyType(key) - when (knownHostsManager.validateHostKey(hostname, port, keyBytes, keyType)) { - KnownHostsManager.HostKeyValidationResult.NEW_HOST -> { - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - return true - } - KnownHostsManager.HostKeyValidationResult.VALID -> { - return true - } - KnownHostsManager.HostKeyValidationResult.KEY_CHANGED -> { - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - return true - } - } - } - - private fun determineKeyType(key: java.security.PublicKey): String { - return when (key.algorithm.lowercase()) { - "rsa" -> "ssh-rsa" - "ec" -> "ecdsa-sha2-nistp256" - "eddsa", "ed25519" -> "ssh-ed25519" - else -> "ssh-${key.algorithm.lowercase()}" - } - } - - override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList { - return mutableListOf() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServersAdapter.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServersAdapter.kt deleted file mode 100644 index f6a1b0f..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServersAdapter.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.example.sshproxy.ui.servers - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.databinding.ItemServerBinding -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class ServersAdapter( - private val onServerClick: (Server) -> Unit, - private val onServerDelete: (Server) -> Unit, - private val keyRepository: KeyRepository, - private val preferencesManager: PreferencesManager, - private val serverRepository: ServerRepository -) : ListAdapter(ServerDiffCallback()) { - - private val testResults = mutableMapOf() - - fun updateTestResult(result: ServerTestResult) { - testResults[result.serverId] = result - // Find the position and notify only that item - val position = currentList.indexOfFirst { it.id == result.serverId } - if (position >= 0) { - notifyItemChanged(position) - } - } - - fun clearTestResults() { - testResults.clear() - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ServerViewHolder { - val binding = ItemServerBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ServerViewHolder(binding) - } - - override fun onBindViewHolder(holder: ServerViewHolder, position: Int) { - val server = getItem(position) - val testResult = testResults[server.id] - holder.bind(server, testResult, onServerClick, onServerDelete, keyRepository, preferencesManager, serverRepository) - } - - class ServerViewHolder(private val binding: ItemServerBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind( - server: Server, - testResult: ServerTestResult?, - onClick: (Server) -> Unit, - onDelete: (Server) -> Unit, - keyRepository: KeyRepository, - preferencesManager: PreferencesManager, - serverRepository: ServerRepository - ) { - binding.tvServerName.text = server.name - binding.tvServerDetails.text = "${server.username}@${server.host}:${server.port}" - - // Показать отпечаток сервера - val fingerprint = serverRepository.getServerFingerprint(server) - if (fingerprint != null) { - binding.tvServerFingerprint.text = "Host key: ${fingerprint.take(20)}..." - binding.tvServerFingerprint.visibility = android.view.View.VISIBLE - } else { - binding.tvServerFingerprint.text = "Host key: Not connected yet" - binding.tvServerFingerprint.visibility = android.view.View.VISIBLE - } - - // Показать информацию о SSH ключе - CoroutineScope(Dispatchers.Main).launch { - val keyInfo = withContext(Dispatchers.IO) { - if (server.sshKeyId != null) { - val key = keyRepository.getKeyById(server.sshKeyId) - key?.name ?: "Unknown key" - } else { - val activeKeyId = preferencesManager.getActiveKeyId() - if (activeKeyId != null) { - val activeKey = keyRepository.getKeyById(activeKeyId) - "Active: ${activeKey?.name ?: "Unknown"}" - } else { - "No key" - } - } - } - binding.tvSshKey.text = keyInfo - } - - binding.root.setOnClickListener { onClick(server) } - binding.btnDelete.setOnClickListener { onDelete(server) } - - // Show test results - if (testResult != null) { - binding.layoutTestResult.visibility = android.view.View.VISIBLE - - when { - testResult.isLoading -> { - binding.tvTestResult.text = "Testing..." - binding.progressBarTest.visibility = android.view.View.VISIBLE - binding.viewQualityIndicator.setBackgroundColor( - android.graphics.Color.GRAY - ) - } - testResult.isSuccess -> { - binding.tvTestResult.text = "${testResult.quality.getDisplayName(binding.root.context)} (${testResult.latencyMs}ms)" - binding.progressBarTest.visibility = android.view.View.GONE - binding.viewQualityIndicator.setBackgroundColor(testResult.quality.color) - } - else -> { - binding.tvTestResult.text = "Failed: ${testResult.errorMessage ?: "Unknown error"}" - binding.progressBarTest.visibility = android.view.View.GONE - binding.viewQualityIndicator.setBackgroundColor( - android.graphics.Color.RED - ) - } - } - } else { - binding.layoutTestResult.visibility = android.view.View.GONE - } - } - } - - class ServerDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Server, newItem: Server): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Server, newItem: Server): Boolean = oldItem == newItem - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServersFragment.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServersFragment.kt deleted file mode 100644 index 81be631..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServersFragment.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.example.sshproxy.ui.servers - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.databinding.FragmentServersBinding -import com.example.sshproxy.network.ConnectionQuality -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.delay - -class ServersFragment : Fragment() { - private var _binding: FragmentServersBinding? = null - private val binding get() = _binding!! - - private val viewModel: ServersViewModel by viewModels { - ServersViewModelFactory( - ServerRepository(requireContext()), - PreferencesManager(requireContext()) - ) - } - private lateinit var adapter: ServersAdapter - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentServersBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupRecyclerView() - - binding.fabAddServer.setOnClickListener { - AddServerDialog { server -> - viewModel.insertServer(server) - }.show(parentFragmentManager, "add_server") - } - - binding.btnTestAllServers.setOnClickListener { - testAllServers() - } - - observeServers() - } - - private fun setupRecyclerView() { - val keyRepository = KeyRepository(requireContext()) - val preferencesManager = PreferencesManager(requireContext()) - val serverRepository = ServerRepository(requireContext()) - - adapter = ServersAdapter( - onServerClick = { server: Server -> - AddServerDialog(server) { updatedServer: Server -> - viewModel.insertServer(updatedServer) - }.show(parentFragmentManager, "edit_server") - }, - onServerDelete = { server: Server -> - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.delete_server)) - .setMessage(getString(com.example.sshproxy.R.string.delete_server_confirmation_with_name, server.name)) - .setPositiveButton(getString(com.example.sshproxy.R.string.delete)) { _, _ -> - viewModel.deleteServer(server) - } - .setNegativeButton(getString(com.example.sshproxy.R.string.cancel), null) - .show() - }, - keyRepository = keyRepository, - preferencesManager = preferencesManager, - serverRepository = serverRepository - ) - - binding.recyclerView.layoutManager = LinearLayoutManager(context) - binding.recyclerView.adapter = adapter - } - - private fun observeServers() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.servers.collectLatest { servers -> - adapter.submitList(servers) - binding.emptyView.visibility = if (servers.isEmpty()) View.VISIBLE else View.GONE - binding.recyclerView.visibility = if (servers.isEmpty()) View.GONE else View.VISIBLE - binding.btnTestAllServers.visibility = if (servers.isEmpty()) View.GONE else View.VISIBLE - } - } - } - - private fun testAllServers() { - val servers = adapter.currentList - if (servers.isEmpty()) { - return - } - - // Disable test button during testing - binding.btnTestAllServers.isEnabled = false - binding.btnTestAllServers.text = "Testing..." - - // Clear previous test results - adapter.clearTestResults() - - val serverTester = ServerTester(requireContext()) - - viewLifecycleOwner.lifecycleScope.launch { - try { - // Test each server sequentially - for (server in servers) { - // Show loading state - adapter.updateTestResult(ServerTestResult.loading(server.id)) - - // Perform the test - val result = serverTester.test(server) - adapter.updateTestResult(result) - - // Small delay between tests to avoid overwhelming the network - delay(500) - } - } finally { - // Re-enable test button - binding.btnTestAllServers.isEnabled = true - binding.btnTestAllServers.text = getString(com.example.sshproxy.R.string.test_all_servers) - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServersViewModel.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServersViewModel.kt deleted file mode 100644 index 45d8585..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServersViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.sshproxy.ui.servers - -import androidx.lifecycle.* -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.PreferencesManager -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -class ServersViewModel(private val repository: ServerRepository, private val preferencesManager: PreferencesManager) : ViewModel() { - val servers: StateFlow> = repository.getAllServers() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - fun insertServer(server: Server) = viewModelScope.launch { - repository.insertServer(server) - val serverList = repository.getAllServers().first() - if (serverList.size == 1) { - preferencesManager.setActiveServerId(serverList.first().id) - } - } - - fun deleteServer(server: Server) = viewModelScope.launch { - repository.deleteServer(server) - val serverList = repository.getAllServers().first() - if (serverList.size == 1) { - preferencesManager.setActiveServerId(serverList.first().id) - } else if (serverList.isEmpty()) { - preferencesManager.setActiveServerId(-1) - } - } -} - -class ServersViewModelFactory(private val repository: ServerRepository, private val preferencesManager: PreferencesManager) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(ServersViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return ServersViewModel(repository, preferencesManager) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/settings/SettingsFragment.kt b/app/src/main/java/com/example/sshproxy/ui/settings/SettingsFragment.kt deleted file mode 100644 index 3d19d7c..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/settings/SettingsFragment.kt +++ /dev/null @@ -1,517 +0,0 @@ -package com.example.sshproxy.ui.settings - -import kotlinx.coroutines.flow.first - -import android.content.Intent -import android.app.Activity -import android.widget.EditText -import android.content.Context -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.SshKey -import com.example.sshproxy.R -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import android.util.Base64 -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.databinding.FragmentSettingsBinding -import com.example.sshproxy.ui.log.LogFragment -import com.example.sshproxy.ui.setup.ServerInstructionsDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import androidx.activity.result.contract.ActivityResultContracts - -class SettingsFragment : Fragment() { - - private lateinit var serverRepository: com.example.sshproxy.data.ServerRepository - private lateinit var keyRepository: com.example.sshproxy.data.KeyRepository - - private fun doImportBackup(json: String) { - viewLifecycleOwner.lifecycleScope.launch { - try { - val gson = Gson() - val mapType = object : TypeToken>() {}.type - val map = gson.fromJson>(json, mapType) - val serversJson = gson.toJson(map["servers"]) - val keysJson = gson.toJson(map["keys"]) - val serversType = object : TypeToken>() {}.type - val servers: List = gson.fromJson(serversJson, serversType) - - // keysJson is a list of maps with meta/encryptedPem/pemPassword - val keysListType = object : TypeToken>>() {}.type - val keysWithPem: List> = gson.fromJson(keysJson, keysListType) - // val keyManager = SshKeyManager(requireContext(), keyRepository) // Не используется - val existingServers = serverRepository.getAllServers().first() - val existingKeys = keyRepository.getAllKeys().first() - val newServers = servers.filter { s -> existingServers.none { it.id == s.id } } - val newKeys = mutableListOf() - - for (keyMap in keysWithPem) { - val metaObj = keyMap["meta"] - val encryptedPem = keyMap["encryptedPem"] as? String - val pemPassword = keyMap["pemPassword"] as? String - // Deserialize meta - val metaJson = gson.toJson(metaObj) - val keyMeta = gson.fromJson(metaJson, SshKey::class.java) - if (existingKeys.none { it.id == keyMeta.id }) { - newKeys.add(keyMeta) - } - // Restore PEM if present - if (encryptedPem != null && pemPassword != null) { - val pem = com.example.sshproxy.security.PemAesUtil.decryptPem(encryptedPem, pemPassword) - // Генерируем новый пароль, шифруем PEM и сохраняем с паролем в Keystore - val keyManagerLocal = SshKeyManager(requireContext(), keyRepository) - keyManagerLocal.saveEncryptedPem(keyMeta.id, pem) - } - // Восстановить файл публичного ключа, если отсутствует - val publicKeyFile = java.io.File(requireContext().filesDir, "ssh_public_${keyMeta.id}") - if (!publicKeyFile.exists() && keyMeta.publicKey.isNotEmpty()) { - try { - // publicKey в SshKey — это строка вида "ssh-ed25519 AAAAC3..." - val parts = keyMeta.publicKey.split(" ") - if (parts.size >= 2) { - val pubKeyBytes = android.util.Base64.decode(parts[1], android.util.Base64.DEFAULT) - publicKeyFile.writeBytes(pubKeyBytes) - } - } catch (_: Exception) {} - } - } - - newServers.forEach { serverRepository.insertServer(it) } - newKeys.forEach { keyRepository.insertKey(it) } - Toast.makeText(requireContext(), R.string.backup_import_success, Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Toast.makeText(requireContext(), R.string.backup_import_error, Toast.LENGTH_LONG).show() - } - } - } - - private suspend fun getAllServersAndKeysJson(): String { - val servers = serverRepository.getAllServers().first() - val keys = keyRepository.getAllKeys().first() - val keyManager = SshKeyManager(requireContext(), keyRepository) - val keysWithPem = keys.map { key -> - val keyId = key.id - val privateKey = keyManager.getPrivateKey(keyId) - val pem = if (privateKey != null) keyManager.convertPrivateKeyToPem(privateKey) else null - val password = if (pem != null) com.example.sshproxy.security.PemAesUtil.generatePassword() else null - val encryptedPem = if (pem != null && password != null) com.example.sshproxy.security.PemAesUtil.encryptPem(pem, password) else null - mapOf( - "meta" to key, - "encryptedPem" to encryptedPem, - "pemPassword" to password - ) - } - val backup = mapOf( - "servers" to servers, - "keys" to keysWithPem - ) - return Gson().toJson(backup) - } - - private fun showImportDialog() { - val options = arrayOf( - getString(R.string.backup_import_file), - getString(R.string.backup_import_text) - ) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.backup_import_title) - .setItems(options) { _, which -> - when (which) { - 0 -> importBackupFromFile() - 1 -> importBackupFromText() - } - } - .show() - } - - private fun importBackupFromFile() { - importBackupLauncher.launch(arrayOf("application/json")) - } - - private fun importBackupFromText() { - val input = EditText(requireContext()) - input.hint = getString(R.string.backup_import_text_hint) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.backup_import_text_title) - .setView(input) - .setPositiveButton(android.R.string.ok) { _, _ -> - val base64 = input.text.toString().trim() - if (base64.isNotEmpty()) { - try { - val json = decodeBackupFromBase64(base64) - doImportBackup(json) - } catch (e: Exception) { - Toast.makeText(requireContext(), R.string.backup_import_error, Toast.LENGTH_LONG).show() - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private val REQUEST_CODE_IMPORT_FILE = 1002 - - private fun showBackupExportDialog() { - val options = arrayOf( - getString(R.string.backup_export_file), - getString(R.string.backup_export_text) - ) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.backup_export_title) - .setItems(options) { _, which -> - when (which) { - 0 -> exportBackupToFile() - 1 -> exportBackupAsText() - } - } - .show() - } - - private val exportBackupLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - uri?.let { - requireContext().contentResolver.openOutputStream(it)?.use { out -> - pendingExportJson?.let { json -> - out.write(json.toByteArray(Charsets.UTF_8)) - } - } - Toast.makeText(requireContext(), R.string.backup_export_success, Toast.LENGTH_SHORT).show() - } - pendingExportJson = null - } - - private val importBackupLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let { - try { - val json = requireContext().contentResolver.openInputStream(it)?.bufferedReader()?.use { reader -> - reader.readText() - } - json?.let { doImportBackup(it) } - } catch (e: Exception) { - Toast.makeText(requireContext(), R.string.backup_import_error, Toast.LENGTH_LONG).show() - } - } - } - - private fun exportBackupToFile() { - viewLifecycleOwner.lifecycleScope.launch { - val json = getAllServersAndKeysJson() - pendingExportJson = json - val fileName = "sshproxy-backup-${System.currentTimeMillis()}.json" - exportBackupLauncher.launch(fileName) - } - } - - private fun exportBackupAsText() { - viewLifecycleOwner.lifecycleScope.launch { - val json = getAllServersAndKeysJson() - val base64 = encodeBackupToBase64(json) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.backup_export_text_title) - .setMessage(base64) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(R.string.backup_export_copy) { _, _ -> - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - clipboard.setPrimaryClip(android.content.ClipData.newPlainText("Backup", base64)) - Toast.makeText(requireContext(), R.string.backup_export_copied, Toast.LENGTH_SHORT).show() - } - .show() - } - } - - private var pendingExportJson: String? = null - private val REQUEST_CODE_EXPORT_FILE = 1001 - // --- Backup/Import helpers --- - private fun encodeBackupToBase64(json: String): String { - return Base64.encodeToString(json.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) - } - - private fun decodeBackupFromBase64(base64: String): String { - return String(Base64.decode(base64, Base64.DEFAULT), Charsets.UTF_8) - } - - private fun parseBackupJson(json: String): Pair, List> { - val gson = Gson() - val mapType = object : TypeToken>() {}.type - val map = gson.fromJson>(json, mapType) - val serversJson = gson.toJson(map["servers"]) - val keysJson = gson.toJson(map["keys"]) - val serversType = object : TypeToken>() {}.type - val keysType = object : TypeToken>() {}.type - val servers: List = gson.fromJson(serversJson, serversType) - val keys: List = gson.fromJson(keysJson, keysType) - return Pair(servers, keys) - } - private var _binding: FragmentSettingsBinding? = null - private val binding get() = _binding!! - - private lateinit var preferencesManager: PreferencesManager - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSettingsBinding.inflate(inflater, container, false) - serverRepository = com.example.sshproxy.data.ServerRepository(requireContext()) - keyRepository = com.example.sshproxy.data.KeyRepository(requireContext()) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - preferencesManager = PreferencesManager(requireContext()) - - loadSettings() - setupListeners() - setupThemeSpinner() - setupLanguageSpinner() - - // Автоматическая генерация ключа при первом запуске - viewLifecycleOwner.lifecycleScope.launch { - val keyManager = SshKeyManager(requireContext(), keyRepository) - val hasKey = keyRepository.getAllKeys().first().isNotEmpty() - if (!hasKey) { - try { - keyManager.generateKeyPair("default") - android.util.Log.d("SettingsFragment", "SSH key generated automatically on first launch") - } catch (e: Exception) { - android.util.Log.e("SettingsFragment", "Failed to generate SSH key: ${e.message}", e) - } - } - } - } - - private fun loadSettings() { - binding.apply { - switchAutoReconnect.isChecked = preferencesManager.isAutoReconnectEnabled() - editHealthCheckInterval.setText((preferencesManager.getHealthCheckInterval() / 1000).toString()) - editMaxReconnectAttempts.setText(preferencesManager.getMaxReconnectAttempts().toString()) - editInitialBackoff.setText((preferencesManager.getInitialBackoffMs() / 1000).toString()) - editMaxBackoff.setText((preferencesManager.getMaxBackoffMs() / 1000).toString()) - editBackoffMultiplier.setText(preferencesManager.getBackoffMultiplier().toString()) - } - } - - private fun setupListeners() { - binding.btnSaveSettings.setOnClickListener { - saveSettings() - } - - binding.btnViewLog.setOnClickListener { - parentFragmentManager.beginTransaction() - .replace(com.example.sshproxy.R.id.fragmentContainer, LogFragment()) - .addToBackStack("settings") - .commit() - } - - binding.btnBackup.setOnClickListener { - android.util.Log.d("SettingsFragment", "Backup button clicked") - showBackupExportDialog() - } - - binding.btnImport.setOnClickListener { - android.util.Log.d("SettingsFragment", "Import button clicked") - showImportDialog() - } - - binding.btnSetup.setOnClickListener { - showServerSetupInstructions() - } - - binding.cardSplitTunneling.setOnClickListener { - parentFragmentManager.beginTransaction() - .replace(com.example.sshproxy.R.id.fragmentContainer, - com.example.sshproxy.ui.splittunneling.SplitTunnelingFragment()) - .addToBackStack("settings") - .commit() - } - - updateSplitTunnelingSubtitle() - } - - override fun onResume() { - super.onResume() - updateSplitTunnelingSubtitle() - } - - private fun updateSplitTunnelingSubtitle() { - val count = preferencesManager.getSplitTunnelingApps().size - binding.tvSplitTunnelingSubtitle.text = if (count == 0) { - getString(R.string.split_tunneling_subtitle_all) - } else { - resources.getQuantityString(R.plurals.split_tunneling_apps_selected, count, count) - } - } - - private fun showServerSetupInstructions() { - lifecycleScope.launch { - try { - val keyManager = SshKeyManager(requireContext(), KeyRepository(requireContext())) - val publicKey = keyManager.getActivePublicKey() - - if (publicKey.isNotEmpty()) { - ServerInstructionsDialog.newInstance(publicKey) - .show(parentFragmentManager, "server_instructions") - } else { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.no_ssh_key)) - .setMessage(getString(com.example.sshproxy.R.string.please_generate_ssh_key)) - .setPositiveButton(getString(com.example.sshproxy.R.string.ok), null) - .show() - } - } catch (e: Exception) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.error)) - .setMessage(getString(com.example.sshproxy.R.string.failed_to_get_ssh_key, e.message)) - .setPositiveButton(getString(com.example.sshproxy.R.string.ok), null) - .show() - } - } - } - - private fun saveSettings() { - try { - binding.apply { - preferencesManager.setAutoReconnectEnabled(switchAutoReconnect.isChecked) - - val healthCheckInterval = editHealthCheckInterval.text.toString().toLongOrNull() ?: 30 - preferencesManager.setHealthCheckInterval(healthCheckInterval * 1000) - - val maxAttempts = editMaxReconnectAttempts.text.toString().toIntOrNull() ?: 10 - preferencesManager.setMaxReconnectAttempts(maxAttempts) - - val initialBackoff = editInitialBackoff.text.toString().toLongOrNull() ?: 1 - preferencesManager.setInitialBackoffMs(initialBackoff * 1000) - - val maxBackoff = editMaxBackoff.text.toString().toLongOrNull() ?: 300 - preferencesManager.setMaxBackoffMs(maxBackoff * 1000) - - val backoffMultiplier = editBackoffMultiplier.text.toString().toFloatOrNull() ?: 2.0f - preferencesManager.setBackoffMultiplier(backoffMultiplier) - } - - Toast.makeText(context, getString(com.example.sshproxy.R.string.settings_saved), Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Toast.makeText(context, getString(com.example.sshproxy.R.string.error_saving_settings, e.message), Toast.LENGTH_LONG).show() - } - } - - private fun setupLanguageSpinner() { - val languages = resources.getStringArray(com.example.sshproxy.R.array.language_entries) - val languageValues = resources.getStringArray(com.example.sshproxy.R.array.language_values) - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, languages) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - binding.spinnerLanguage.adapter = adapter - - val currentLanguage = preferencesManager.getLanguage() - val currentLanguageIndex = languageValues.indexOf(currentLanguage) - if (currentLanguageIndex != -1) { - binding.spinnerLanguage.setSelection(currentLanguageIndex) - } - - binding.spinnerLanguage.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val selectedLanguage = languageValues[position] - preferencesManager.setLanguage(selectedLanguage) - applyLanguage(selectedLanguage) - } - - override fun onNothingSelected(parent: AdapterView<*>?) {} - } - } - - private fun applyLanguage(language: String) { - val localeList = if (language == "system") { - LocaleListCompat.getEmptyLocaleList() - } else { - LocaleListCompat.forLanguageTags(language) - } - AppCompatDelegate.setApplicationLocales(localeList) - } - - private fun setupThemeSpinner() { - val themes = resources.getStringArray(com.example.sshproxy.R.array.theme_entries) - val themeValues = resources.getStringArray(com.example.sshproxy.R.array.theme_values) - - // Добавим отладочный лог - android.util.Log.d("SettingsFragment", "Available themes: ${themes.joinToString()}") - android.util.Log.d("SettingsFragment", "Available theme values: ${themeValues.joinToString()}") - - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, themes) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - binding.spinnerTheme.adapter = adapter - - val currentTheme = preferencesManager.getTheme() - android.util.Log.d("SettingsFragment", "Current theme from preferences: $currentTheme") - - val currentThemeIndex = themeValues.indexOf(currentTheme) - if (currentThemeIndex != -1) { - binding.spinnerTheme.setSelection(currentThemeIndex) - } - - var isInitialLoad = true - binding.spinnerTheme.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - // Игнорируем первый вызов при загрузке - if (isInitialLoad) { - isInitialLoad = false - return - } - - val selectedTheme = themeValues[position] - android.util.Log.d("SettingsFragment", "Theme selected: $selectedTheme, current: ${preferencesManager.getTheme()}") - - if (selectedTheme != preferencesManager.getTheme()) { - preferencesManager.setTheme(selectedTheme) - android.util.Log.d("SettingsFragment", "Theme saved to preferences: $selectedTheme") - - // Применяем тему немедленно - when (selectedTheme) { - "light" -> { - android.util.Log.d("SettingsFragment", "Applying light theme") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - "dark" -> { - android.util.Log.d("SettingsFragment", "Applying dark theme") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - } - else -> { - android.util.Log.d("SettingsFragment", "Applying system theme") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - } - } - - // Показываем сообщение и перезапускаем активити - if (isAdded && !isDetached && activity != null) { - Toast.makeText(context, "Theme changed to $selectedTheme", Toast.LENGTH_SHORT).show() - - // Перезапускаем активити - android.util.Log.d("SettingsFragment", "Recreating activity") - requireActivity().recreate() - } - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) {} - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/AddFirstServerFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/AddFirstServerFragment.kt deleted file mode 100644 index 071e83f..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/AddFirstServerFragment.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.MainActivity -import com.example.sshproxy.R -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.databinding.FragmentAddFirstServerBinding -import kotlinx.coroutines.launch - -class AddFirstServerFragment : Fragment() { - private var _binding: FragmentAddFirstServerBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentAddFirstServerBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupUI() - } - - private fun setupUI() { - // Auto-fill name from host with URL cleanup - binding.etHost.doAfterTextChanged { text -> - if (!text.isNullOrEmpty()) { - val cleanHost = cleanUrl(text.toString()) - - // Update host field if URL was cleaned - if (cleanHost != text.toString()) { - binding.etHost.setText(cleanHost) - binding.etHost.setSelection(cleanHost.length) // Move cursor to end - } - - // Auto-fill name only if it's empty - if (binding.etName.text.isNullOrEmpty()) { - binding.etName.setText(cleanHost) - } - } - } - - // Toggle advanced options - binding.btnToggleAdvanced.setOnClickListener { - val isVisible = binding.advancedOptionsLayout.visibility == View.VISIBLE - binding.advancedOptionsLayout.visibility = if (isVisible) View.GONE else View.VISIBLE - binding.btnToggleAdvanced.setIconResource( - if (isVisible) com.example.sshproxy.R.drawable.ic_expand_more - else com.example.sshproxy.R.drawable.ic_expand_less - ) - } - - binding.btnAddServer.setOnClickListener { - addServer() - } - - binding.btnSkip.setOnClickListener { - completeSetup() - } - } - - private fun addServer() { - val rawHost = binding.etHost.text.toString().trim() - val cleanHost = cleanUrl(rawHost) - val nameText = binding.etName.text?.toString()?.trim() - val name = if (!nameText.isNullOrEmpty()) nameText else cleanHost - val user = binding.etUser.text?.toString()?.trim() ?: "user" - val port = binding.etPort.text?.toString()?.toIntOrNull() ?: 22 - - if (cleanHost.isEmpty()) { - Toast.makeText(context, "Please enter a server host", Toast.LENGTH_SHORT).show() - return - } - - lifecycleScope.launch { - try { - val serverRepository = ServerRepository(requireContext()) - val server = Server( - name = name, - host = cleanHost, - port = port, - username = user - ) - serverRepository.insertServer(server) - Toast.makeText(context, getString(com.example.sshproxy.R.string.server_added), Toast.LENGTH_SHORT).show() - (activity as? MainActivity)?.completeSetup() - } catch (e: Exception) { - Toast.makeText(context, getString(com.example.sshproxy.R.string.error_adding_server, e.message), Toast.LENGTH_LONG).show() - } - } - } - - private fun completeSetup() { - (activity as? MainActivity)?.completeSetup() - } - - private fun cleanUrl(input: String): String { - var cleaned = input.trim() - - // Remove common URL prefixes - val prefixes = listOf("http://", "https://", "ftp://", "ftps://") - for (prefix in prefixes) { - if (cleaned.startsWith(prefix, ignoreCase = true)) { - cleaned = cleaned.substring(prefix.length) - break - } - } - - // Remove trailing slashes and paths - val slashIndex = cleaned.indexOf('/') - if (slashIndex != -1) { - cleaned = cleaned.substring(0, slashIndex) - } - - // Remove port from display (user can set it separately) - val colonIndex = cleaned.lastIndexOf(':') - if (colonIndex != -1) { - // Check if what follows the colon is a number (port) - val afterColon = cleaned.substring(colonIndex + 1) - if (afterColon.toIntOrNull() != null) { - cleaned = cleaned.substring(0, colonIndex) - } - } - - return cleaned - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/KeyChoiceFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/KeyChoiceFragment.kt deleted file mode 100644 index ebf8e33..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/KeyChoiceFragment.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.example.sshproxy.MainActivity -import com.example.sshproxy.databinding.FragmentKeyChoiceBinding - -class KeyChoiceFragment : Fragment() { - private var _binding: FragmentKeyChoiceBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentKeyChoiceBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.btnGenerate.setOnClickListener { - (activity as? MainActivity)?.navigateToKeyGeneration() - } - - binding.btnSkip.setOnClickListener { - // Переход к добавлению сервера без генерации ключа - (activity as? MainActivity)?.navigateToAddServer() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/KeyGenerationFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/KeyGenerationFragment.kt deleted file mode 100644 index 05f576b..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/KeyGenerationFragment.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.MainActivity -import com.example.sshproxy.R -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.databinding.FragmentKeyGenerationBinding -import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.first - -class KeyGenerationFragment : Fragment() { - private var _binding: FragmentKeyGenerationBinding? = null - private val binding get() = _binding!! - private var publicKey: String = "" - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentKeyGenerationBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - checkOrGenerateKey() - - binding.btnCopyKey.setOnClickListener { - copyPublicKey() - } - - binding.btnServerSetup.setOnClickListener { - showServerSetupInstructions() - } - - binding.btnNext.setOnClickListener { - (activity as? MainActivity)?.navigateToAddServer() - } - } - - private fun checkOrGenerateKey() { - lifecycleScope.launch { - val keyRepository = KeyRepository(requireContext()) - val keyManager = SshKeyManager(requireContext(), keyRepository) - val preferencesManager = com.example.sshproxy.data.PreferencesManager(requireContext()) - val allKeys = keyRepository.getAllKeys().first() - if (allKeys.isNotEmpty()) { - // Если есть хотя бы один ключ, не генерируем новый, просто используем первый - val existingKey = allKeys.first() - publicKey = existingKey.publicKey - binding.tvPublicKey.text = publicKey - preferencesManager.setActiveKeyId(existingKey.id) - } else { - try { - val generatedKey = keyManager.generateKeyPair("Default Key") - publicKey = generatedKey.publicKey - binding.tvPublicKey.text = publicKey - preferencesManager.setActiveKeyId(generatedKey.id) - } catch (e: Exception) { - binding.tvPublicKey.text = getString(com.example.sshproxy.R.string.error_generating_key_with_message, e.message) - Toast.makeText(context, getString(com.example.sshproxy.R.string.failed_to_generate_ssh_key), Toast.LENGTH_LONG).show() - } - } - } - } - - private fun copyPublicKey() { - if (publicKey.isNotEmpty()) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(getString(R.string.ssh_public_key), publicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, getString(R.string.public_key_copied), Toast.LENGTH_SHORT).show() - } - } - - private fun showServerSetupInstructions() { - if (publicKey.isNotEmpty()) { - ServerInstructionsDialog.newInstance(publicKey) - .show(parentFragmentManager, "server_instructions") - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/KeySetupFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/KeySetupFragment.kt deleted file mode 100644 index f2efb45..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/KeySetupFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.SshKey -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.databinding.FragmentKeySetupBinding -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -class KeySetupFragment : Fragment() { - private var _binding: FragmentKeySetupBinding? = null - private val binding get() = _binding!! - private lateinit var keyManager: SshKeyManager - private lateinit var preferencesManager: PreferencesManager - private var currentKey: SshKey? = null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentKeySetupBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val keyRepository = KeyRepository(requireContext()) - keyManager = SshKeyManager(requireContext(), keyRepository) - preferencesManager = PreferencesManager(requireContext()) - - lifecycleScope.launch { - val allKeys = keyRepository.getAllKeys().first() - if (allKeys.isNotEmpty()) { - // Если есть хотя бы один ключ, не генерируем новый, просто используем первый - currentKey = allKeys.firstOrNull() - // Можно выставить активный, если нужно - currentKey?.let { preferencesManager.setActiveKeyId(it.id) } - } else { - // Только если ключей нет вообще, генерируем новый - currentKey = keyManager.generateKeyPair("Default Key") - currentKey?.let { preferencesManager.setActiveKeyId(it.id) } - } - - if (currentKey != null) { - binding.tvPublicKey.text = currentKey!!.publicKey - } else { - binding.tvPublicKey.text = "Error: Could not load or generate a key." - binding.btnNext.isEnabled = false - } - } - - binding.btnCopyKey.setOnClickListener { - copyKeyToClipboard() - } - - binding.btnServerInstructions.setOnClickListener { - showServerInstructions() - } - - binding.btnNext.setOnClickListener { - val fragment = ServerSetupFragment() - parentFragmentManager.beginTransaction() - .replace(com.example.sshproxy.R.id.fragmentContainer, fragment) - .addToBackStack(null) - .commit() - } - } - - private fun copyKeyToClipboard() { - currentKey?.let { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("SSH Public Key", it.publicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show() - } - } - - private fun showServerInstructions() { - currentKey?.let { - ServerInstructionsDialog.newInstance(it.publicKey).show(parentFragmentManager, "instructions") - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/ServerInstructionsDialog.kt b/app/src/main/java/com/example/sshproxy/ui/setup/ServerInstructionsDialog.kt deleted file mode 100644 index c7a47a1..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/ServerInstructionsDialog.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.app.Dialog -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.widget.Toast -import androidx.fragment.app.DialogFragment -import com.example.sshproxy.databinding.DialogServerInstructionsBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class ServerInstructionsDialog : DialogFragment() { - - companion object { - private const val ARG_PUBLIC_KEY = "public_key" - fun newInstance(publicKey: String): ServerInstructionsDialog { - val args = Bundle() - args.putString(ARG_PUBLIC_KEY, publicKey) - val fragment = ServerInstructionsDialog() - fragment.arguments = args - return fragment - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = DialogServerInstructionsBinding.inflate(layoutInflater) - val publicKey = arguments?.getString(ARG_PUBLIC_KEY) - - if (publicKey != null) { - val instructions = generateInstructions(publicKey) - binding.tvInstructions.text = instructions - binding.btnCopyInstructions.setOnClickListener { - copyToClipboard(instructions) - } - } else { - binding.tvInstructions.text = getString(com.example.sshproxy.R.string.no_ssh_key_configured) + "\n" + getString(com.example.sshproxy.R.string.please_generate_ssh_key) - binding.btnCopyInstructions.isEnabled = false - } - - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.server_setup_instructions_title)) - .setView(binding.root) - .setPositiveButton(getString(com.example.sshproxy.R.string.close), null) - .setNegativeButton(getString(com.example.sshproxy.R.string.skip_for_now)) { _, _ -> dismiss() } - .create() - return dialog - } - - private fun generateInstructions(publicKey: String): String { - return """ -# SSH Server Setup Instructions - -## Quick automated setup (copy and run as root): -bash <(cat <<'SCRIPT' -# Detect package manager -if command -v apt-get >/dev/null 2>&1; then - PKG_UPDATE="apt-get update" - PKG_INSTALL="apt-get install -y" -elif command -v yum >/dev/null 2>&1; then - PKG_UPDATE="yum check-update || true" - PKG_INSTALL="yum install -y" -elif command -v dnf >/dev/null 2>&1; then - PKG_UPDATE="dnf check-update || true" - PKG_INSTALL="dnf install -y" -elif command -v pacman >/dev/null 2>&1; then - PKG_UPDATE="pacman -Sy" - PKG_INSTALL="pacman -S --noconfirm" -else - echo "No supported package manager found!"; exit 1 -fi - -# Auto-detect SSH service name -if systemctl is-active --quiet ssh; then - SSH_SERVICE="ssh" -elif systemctl is-active --quiet sshd; then - SSH_SERVICE="sshd" -else - SSH_SERVICE="ssh" # Default to ssh for Ubuntu/Debian -fi - -echo "SSH service detected: ${'$'}SSH_SERVICE" - -# Setup user -adduser --disabled-password --gecos "" --home /home/user user -usermod -s /usr/sbin/nologin user -install -d -m 700 -o user -g user /home/user/.ssh -echo "restrict,port-forwarding $publicKey" > /home/user/.ssh/authorized_keys -chown user:user /home/user/.ssh/authorized_keys -chmod 600 /home/user/.ssh/authorized_keys - -# Configure SSH -cat >/etc/ssh/sshd_config.d/user.conf < - if (binding.etName.text.isNullOrEmpty() && !text.isNullOrEmpty()) { - binding.etName.setText(text.toString()) - } - } - - // Toggle advanced options - binding.btnToggleAdvanced.setOnClickListener { - val isVisible = binding.advancedOptionsLayout.visibility == View.VISIBLE - binding.advancedOptionsLayout.visibility = if (isVisible) View.GONE else View.VISIBLE - binding.btnToggleAdvanced.setIconResource( - if (isVisible) com.example.sshproxy.R.drawable.ic_expand_more - else com.example.sshproxy.R.drawable.ic_expand_less - ) - } - - binding.btnSave.setOnClickListener { - saveServerAndFinish() - } - - binding.btnSkip.setOnClickListener { - finishSetup() - } - } - - private fun saveServerAndFinish() { - val host = binding.etHost.text.toString().trim() - - if (host.isEmpty()) { - Toast.makeText(context, "Please enter a host", Toast.LENGTH_SHORT).show() - return - } - - val nameText = binding.etName.text?.toString()?.trim() - val name = if (!nameText.isNullOrEmpty()) nameText else host - val user = binding.etUser.text?.toString()?.trim() ?: "sshproxy" - val port = binding.etPort.text?.toString()?.toIntOrNull() ?: 22 - - val server = Server( - name = name, - host = host, - port = port, - username = user - ) - - lifecycleScope.launch { - serverRepository.insertServer(server) - Toast.makeText(context, "Server added", Toast.LENGTH_SHORT).show() - finishSetup() - } - } - - private fun finishSetup() { - setupManager.completeSetup() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/SetupManager.kt b/app/src/main/java/com/example/sshproxy/ui/setup/SetupManager.kt deleted file mode 100644 index b8298f3..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/SetupManager.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.content.Context -import android.content.SharedPreferences -import com.example.sshproxy.MainActivity - -class SetupManager(private val context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences("setup_prefs", Context.MODE_PRIVATE) - - companion object { - private const val KEY_SETUP_COMPLETE = "setup_complete" - private const val KEY_FIRST_LAUNCH = "first_launch" - } - - fun isSetupComplete(): Boolean { - return prefs.getBoolean(KEY_SETUP_COMPLETE, false) - } - - fun isFirstLaunch(): Boolean { - return prefs.getBoolean(KEY_FIRST_LAUNCH, true) - } - - fun showSetupFlow() { - prefs.edit().putBoolean(KEY_FIRST_LAUNCH, false).apply() - (context as? MainActivity)?.showSetupFlow() - } - - fun completeSetup() { - prefs.edit() - .putBoolean(KEY_SETUP_COMPLETE, true) - .putBoolean(KEY_FIRST_LAUNCH, false) - .apply() - (context as? MainActivity)?.navigateToHome() - } - - fun markFirstLaunchComplete() { - prefs.edit().putBoolean(KEY_FIRST_LAUNCH, false).apply() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/WelcomeFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/WelcomeFragment.kt deleted file mode 100644 index 38db5ef..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/WelcomeFragment.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.example.sshproxy.MainActivity -import com.example.sshproxy.databinding.FragmentWelcomeBinding - -class WelcomeFragment : Fragment() { - private var _binding: FragmentWelcomeBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentWelcomeBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.btnGetStarted.setOnClickListener { - (activity as? MainActivity)?.navigateToKeySetup() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppInfo.kt b/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppInfo.kt deleted file mode 100644 index e6e6cbf..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppInfo.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.sshproxy.ui.splittunneling - -data class AppInfo( - val packageName: String, - val appName: String, - val isSystem: Boolean, - val isSelected: Boolean -) diff --git a/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppListAdapter.kt b/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppListAdapter.kt deleted file mode 100644 index 6951707..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppListAdapter.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.sshproxy.ui.splittunneling - -import android.graphics.drawable.Drawable -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.example.sshproxy.databinding.ItemAppBinding - -class AppListAdapter( - private val onToggle: (packageName: String, selected: Boolean) -> Unit -) : ListAdapter(AppDiffCallback()) { - - private val iconCache = HashMap() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder { - val binding = ItemAppBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return AppViewHolder(binding) - } - - override fun onBindViewHolder(holder: AppViewHolder, position: Int) { - holder.bind(getItem(position), iconCache, onToggle) - } - - class AppViewHolder(private val binding: ItemAppBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind( - app: AppInfo, - iconCache: HashMap, - onToggle: (String, Boolean) -> Unit - ) { - binding.tvAppName.text = app.appName - binding.tvPackageName.text = app.packageName - - val icon = iconCache.getOrPut(app.packageName) { - try { - binding.root.context.packageManager.getApplicationIcon(app.packageName) - } catch (e: Exception) { - binding.root.context.packageManager.defaultActivityIcon - } - } - binding.imgAppIcon.setImageDrawable(icon) - - // Remove listener before setting checked state to avoid callback loops - binding.checkboxSelected.setOnCheckedChangeListener(null) - binding.checkboxSelected.isChecked = app.isSelected - binding.checkboxSelected.setOnCheckedChangeListener { _, isChecked -> - onToggle(app.packageName, isChecked) - } - - binding.root.setOnClickListener { - binding.checkboxSelected.toggle() - } - } - } - - class AppDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: AppInfo, newItem: AppInfo) = - oldItem.packageName == newItem.packageName - - override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) = - oldItem.isSelected == newItem.isSelected && oldItem.appName == newItem.appName - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingFragment.kt b/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingFragment.kt deleted file mode 100644 index a9bd2c0..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingFragment.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.example.sshproxy.ui.splittunneling - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.example.sshproxy.R -import com.example.sshproxy.SshProxyService -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.databinding.FragmentSplitTunnelingBinding -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class SplitTunnelingFragment : Fragment() { - - private var _binding: FragmentSplitTunnelingBinding? = null - private val binding get() = _binding!! - - private val viewModel: SplitTunnelingViewModel by viewModels { - SplitTunnelingViewModel.Factory( - requireContext().packageManager, - PreferencesManager(requireContext()) - ) - } - - private lateinit var adapter: AppListAdapter - private var reconnectSnackbar: Snackbar? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSplitTunnelingBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - setupToolbar() - setupRecyclerView() - setupSearch() - setupSystemAppsToggle() - observeViewModel() - observeVpnState() - } - - private fun setupToolbar() { - binding.toolbar.setNavigationOnClickListener { - parentFragmentManager.popBackStack() - } - } - - private fun setupRecyclerView() { - adapter = AppListAdapter { packageName, selected -> - viewModel.toggleApp(packageName, selected) - } - binding.recyclerApps.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerApps.adapter = adapter - } - - private fun setupSearch() { - binding.editSearch.addTextChangedListener { editable -> - viewModel.setSearchQuery(editable?.toString() ?: "") - } - } - - private fun setupSystemAppsToggle() { - binding.switchSystemApps.setOnCheckedChangeListener { _, isChecked -> - viewModel.setShowSystemApps(isChecked) - } - } - - private fun observeViewModel() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.isLoading.collectLatest { loading -> - binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE - binding.recyclerApps.visibility = if (loading) View.GONE else View.VISIBLE - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.filteredApps.collectLatest { apps -> - adapter.submitList(apps) - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.selectedCount.collectLatest { count -> - binding.tvSelectedCount.text = if (count == 0) { - getString(R.string.split_tunneling_no_apps_selected) - } else { - resources.getQuantityString( - R.plurals.split_tunneling_apps_selected, count, count - ) - } - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.showSystemApps.collectLatest { show -> - if (binding.switchSystemApps.isChecked != show) { - binding.switchSystemApps.isChecked = show - } - } - } - } - - private fun observeVpnState() { - viewLifecycleOwner.lifecycleScope.launch { - SshProxyService.isRunning.collectLatest { running -> - if (running) { - if (reconnectSnackbar == null) { - reconnectSnackbar = Snackbar.make( - binding.root, - R.string.split_tunneling_reconnect_notice, - Snackbar.LENGTH_INDEFINITE - ).also { it.show() } - } - } else { - reconnectSnackbar?.dismiss() - reconnectSnackbar = null - } - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - reconnectSnackbar = null - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingViewModel.kt b/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingViewModel.kt deleted file mode 100644 index 04b3935..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingViewModel.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.example.sshproxy.ui.splittunneling - -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.example.sshproxy.data.PreferencesManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class SplitTunnelingViewModel( - private val packageManager: PackageManager, - private val preferencesManager: PreferencesManager -) : ViewModel() { - - private val _allApps = MutableStateFlow>(emptyList()) - - private val _filteredApps = MutableStateFlow>(emptyList()) - val filteredApps: StateFlow> = _filteredApps.asStateFlow() - - private val _selectedCount = MutableStateFlow(0) - val selectedCount: StateFlow = _selectedCount.asStateFlow() - - private val _isLoading = MutableStateFlow(true) - val isLoading: StateFlow = _isLoading.asStateFlow() - - private val _showSystemApps = MutableStateFlow(false) - val showSystemApps: StateFlow = _showSystemApps.asStateFlow() - - private var searchQuery: String = "" - - init { - loadApps() - } - - private fun loadApps() { - viewModelScope.launch { - val apps = withContext(Dispatchers.IO) { - val savedPackages = preferencesManager.getSplitTunnelingApps().toMutableSet() - val installedApps = packageManager.getInstalledApplications(PackageManager.GET_META_DATA) - val installedSet = installedApps.map { it.packageName }.toSet() - - // Clean up stale entries (uninstalled apps) - val stale = savedPackages - installedSet - if (stale.isNotEmpty()) { - savedPackages -= stale - preferencesManager.setSplitTunnelingApps(savedPackages) - } - - installedApps - .map { appInfo -> - val isSystem = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 - AppInfo( - packageName = appInfo.packageName, - appName = packageManager.getApplicationLabel(appInfo).toString(), - isSystem = isSystem, - isSelected = appInfo.packageName in savedPackages - ) - } - .sortedWith(compareBy({ !it.isSelected }, { it.appName.lowercase() })) - } - _allApps.value = apps - _selectedCount.value = apps.count { it.isSelected } - _isLoading.value = false - applyFilter() - } - } - - fun setSearchQuery(query: String) { - searchQuery = query - applyFilter() - } - - fun setShowSystemApps(show: Boolean) { - _showSystemApps.value = show - applyFilter() - } - - private fun applyFilter() { - val base = _allApps.value - val showSystem = _showSystemApps.value - val query = searchQuery.trim().lowercase() - - _filteredApps.value = base - .filter { if (!showSystem) !it.isSystem else true } - .filter { if (query.isNotEmpty()) it.appName.lowercase().contains(query) else true } - } - - fun toggleApp(packageName: String, selected: Boolean) { - _allApps.value = _allApps.value.map { app -> - if (app.packageName == packageName) app.copy(isSelected = selected) else app - } - _selectedCount.value = _allApps.value.count { it.isSelected } - applyFilter() - - val newSet = _allApps.value - .filter { it.isSelected } - .map { it.packageName } - .toSet() - preferencesManager.setSplitTunnelingApps(newSet) - } - - class Factory( - private val packageManager: PackageManager, - private val preferencesManager: PreferencesManager - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - SplitTunnelingViewModel(packageManager, preferencesManager) as T - } -} diff --git a/app/src/main/java/com/example/sshproxy/widget/VPNStatusWidgetProvider.kt b/app/src/main/java/com/example/sshproxy/widget/VPNStatusWidgetProvider.kt deleted file mode 100644 index e359a71..0000000 --- a/app/src/main/java/com/example/sshproxy/widget/VPNStatusWidgetProvider.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.example.sshproxy.widget - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.Context -import android.content.Intent -import android.widget.RemoteViews -import com.example.sshproxy.R - -import android.os.Build -import com.example.sshproxy.SshProxyService -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.delay -import android.os.Handler -import android.os.Looper -import android.view.View -import java.util.Timer -import java.util.TimerTask - -class VPNStatusWidgetProvider : AppWidgetProvider() { - companion object { - const val ACTION_TOGGLE_VPN = "com.example.sshproxy.widget.ACTION_TOGGLE_VPN" - private var lastVpnConnected = false - private var blinkTimer: Timer? = null - private var isBlinkOn = true - - private fun isVpnServiceRunning(context: Context): Boolean { - val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager - for (service in manager.getRunningServices(Integer.MAX_VALUE)) { - if (SshProxyService::class.java.name == service.service.className) { - return true - } - } - return false - } - fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { - val views = RemoteViews(context.packageName, R.layout.widget_vpn_status) - val prefs = context.getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - val serverId = prefs.getLong("active_server_id", -1) - - if (serverId == -1L) { - views.setViewVisibility(R.id.tv_widget_message, View.VISIBLE) - views.setViewVisibility(R.id.iv_status_circle, View.GONE) - views.setViewVisibility(R.id.iv_ssh_icon, View.GONE) - } else { - views.setViewVisibility(R.id.tv_widget_message, View.GONE) - views.setViewVisibility(R.id.iv_status_circle, View.VISIBLE) - views.setViewVisibility(R.id.iv_ssh_icon, View.VISIBLE) - - // Проверяем реальное состояние VPN Service - val isServiceRunning = isVpnServiceRunning(context) - - // Если сервис не запущен, сбрасываем флаги - if (!isServiceRunning) { - prefs.edit() - .putBoolean("vpn_running", false) - .putBoolean("vpn_connecting", false) - .apply() - } - - val isVpnRunning = prefs.getBoolean("vpn_running", false) && isServiceRunning - val isConnecting = prefs.getBoolean("vpn_connecting", false) - lastVpnConnected = isVpnRunning - - android.util.Log.d("VPNStatusWidget", "updateAppWidget: isVpnRunning=$isVpnRunning, isConnecting=$isConnecting, isBlinkOn=$isBlinkOn, serviceRunning=$isServiceRunning") - - val circleColor = when { - isConnecting -> { - // Blinking when connecting - if (isBlinkOn) { - context.getColor(R.color.widget_connecting) - } else { - context.getColor(R.color.widget_disconnected) - } - } - isVpnRunning -> { - context.getColor(R.color.widget_connected) - } - else -> { - context.getColor(R.color.widget_disconnected) - } - } - - views.setInt(R.id.iv_status_circle, "setColorFilter", circleColor) - } - - // The icon and background remain static - views.setInt(R.id.iv_ssh_icon, "setImageResource", R.drawable.ic_ssh) - views.setInt(R.id.widget_root, "setBackgroundResource", R.drawable.widget_background) - - - val intent = Intent(context, VPNStatusWidgetProvider::class.java).apply { - action = ACTION_TOGGLE_VPN - } - val pendingIntent = PendingIntent.getBroadcast(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - views.setOnClickPendingIntent(R.id.btnToggleVpn, pendingIntent) - - appWidgetManager.updateAppWidget(appWidgetId, views) - } - - fun startBlinking(context: Context) { - stopBlinking() - blinkTimer = Timer() - blinkTimer?.scheduleAtFixedRate(object : TimerTask() { - override fun run() { - isBlinkOn = !isBlinkOn - Handler(Looper.getMainLooper()).post { - val appWidgetManager = AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, VPNStatusWidgetProvider::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - } - }, 0, 500) // Моргание каждые 500мс - } - - fun stopBlinking(context: Context? = null) { - android.util.Log.d("VPNStatusWidget", "stopBlinking called") - blinkTimer?.cancel() - blinkTimer = null - isBlinkOn = true - - // Если передан контекст, обновляем виджет немедленно - if (context != null) { - android.os.Handler(android.os.Looper.getMainLooper()).post { - val appWidgetManager = AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, VPNStatusWidgetProvider::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - } - } - } - - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - android.util.Log.d("VPNStatusWidget", "onReceive: action=${intent.action}") - if (intent.action == ACTION_TOGGLE_VPN) { - android.util.Log.d("VPNStatusWidget", "ACTION_TOGGLE_VPN received") - val prefs = context.getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - val isVpnRunning = prefs.getBoolean("vpn_running", false) - val serverId = prefs.getLong("active_server_id", -1) - if (serverId == -1L && !isVpnRunning) { - // Do nothing if no server is selected - return - } - android.util.Log.d("VPNStatusWidget", "isVpnRunning=$isVpnRunning, serverId=$serverId") - if (isVpnRunning) { - android.util.Log.d("VPNStatusWidget", "Stopping VPN") - // Проверяем что сервис действительно запущен перед остановкой - if (isVpnServiceRunning(context)) { - // Отключение VPN можно делать напрямую - val serviceIntent = Intent(context, SshProxyService::class.java) - serviceIntent.action = SshProxyService.ACTION_STOP - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) - } - } catch (e: Exception) { - android.util.Log.e("VPNStatusWidget", "Error stopping VPN service", e) - // Сбрасываем флаги если сервис не запущен - prefs.edit() - .putBoolean("vpn_running", false) - .putBoolean("vpn_connecting", false) - .apply() - // Обновляем виджет - val appWidgetManager = AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, VPNStatusWidgetProvider::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - } else { - android.util.Log.d("VPNStatusWidget", "VPN service not running, resetting flags") - // Сбрасываем флаги если сервис не запущен - prefs.edit() - .putBoolean("vpn_running", false) - .putBoolean("vpn_connecting", false) - .apply() - // Обновляем виджет - val appWidgetManager = AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, VPNStatusWidgetProvider::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - } else { - android.util.Log.d("VPNStatusWidget", "Starting VPN via VpnPermissionActivity") - // Для подключения VPN используем прозрачную activity - val activityIntent = Intent(context, com.example.sshproxy.VpnPermissionActivity::class.java).apply { - putExtra("server_id", serverId) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS - } - context.startActivity(activityIntent) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/hev/sockstun/TProxyService.java b/app/src/main/java/hev/sockstun/TProxyService.java new file mode 100644 index 0000000..2b1a1a2 --- /dev/null +++ b/app/src/main/java/hev/sockstun/TProxyService.java @@ -0,0 +1,12 @@ +package hev.sockstun; + +public class TProxyService { + public static native void TProxyStartService(String configPath, int fd); + public static native void TProxyStopService(); + public static native boolean TProxyIsRunning(); + public static native long[] TProxyGetStats(); + + static { + System.loadLibrary("hev-socks5-tunnel"); + } +} diff --git a/app/src/main/jniLibs/arm64-v8a/.gitkeep b/app/src/main/jniLibs/arm64-v8a/.gitkeep new file mode 100644 index 0000000..c37343c --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/.gitkeep @@ -0,0 +1 @@ +for arm64-v8a diff --git a/app/src/main/jniLibs/arm64-v8a/libhev-socks5-tunnel.so b/app/src/main/jniLibs/arm64-v8a/libhev-socks5-tunnel.so new file mode 100644 index 0000000..7024815 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libhev-socks5-tunnel.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/.gitkeep b/app/src/main/jniLibs/armeabi-v7a/.gitkeep new file mode 100644 index 0000000..54127e0 --- /dev/null +++ b/app/src/main/jniLibs/armeabi-v7a/.gitkeep @@ -0,0 +1 @@ +armeabi-v7a diff --git a/app/src/main/jniLibs/armeabi-v7a/libhev-socks5-tunnel.so b/app/src/main/jniLibs/armeabi-v7a/libhev-socks5-tunnel.so new file mode 100644 index 0000000..c6d8408 Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libhev-socks5-tunnel.so differ diff --git a/app/src/main/jniLibs/x86/.gitkeep b/app/src/main/jniLibs/x86/.gitkeep new file mode 100644 index 0000000..7306afa --- /dev/null +++ b/app/src/main/jniLibs/x86/.gitkeep @@ -0,0 +1 @@ +x86 diff --git a/app/src/main/jniLibs/x86/libhev-socks5-tunnel.so b/app/src/main/jniLibs/x86/libhev-socks5-tunnel.so new file mode 100644 index 0000000..a2d5845 Binary files /dev/null and b/app/src/main/jniLibs/x86/libhev-socks5-tunnel.so differ diff --git a/app/src/main/jniLibs/x86_64/.gitkeep b/app/src/main/jniLibs/x86_64/.gitkeep new file mode 100644 index 0000000..1c09346 --- /dev/null +++ b/app/src/main/jniLibs/x86_64/.gitkeep @@ -0,0 +1 @@ +x86_64 diff --git a/app/src/main/jniLibs/x86_64/libhev-socks5-tunnel.so b/app/src/main/jniLibs/x86_64/libhev-socks5-tunnel.so new file mode 100644 index 0000000..914908e Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libhev-socks5-tunnel.so differ diff --git a/app/src/main/res/layout/activity_http_custom.xml b/app/src/main/res/layout/activity_http_custom.xml new file mode 100644 index 0000000..8029ae1 --- /dev/null +++ b/app/src/main/res/layout/activity_http_custom.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 41dcc2f..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,323 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt b/app/src/main/res/layout/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt new file mode 100644 index 0000000..88d85c4 --- /dev/null +++ b/app/src/main/res/layout/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt @@ -0,0 +1,103 @@ +package com.example.sshproxy + +import android.os.Bundle +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity + +class HttpCustomActivity : AppCompatActivity() { + + private lateinit var sshHostInput: EditText + private lateinit var sshPortInput: EditText + private lateinit var sshUsernameInput: EditText + private lateinit var sshPasswordInput: EditText + private lateinit var proxyHostInput: EditText + private lateinit var proxyPortInput: EditText + private lateinit var payloadInput: EditText + private lateinit var connectButton: Button + private lateinit var disconnectButton: Button + private lateinit var statusText: TextView + private lateinit var logText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_http_custom) + + sshHostInput = findViewById(R.id.sshHostInput) + sshPortInput = findViewById(R.id.sshPortInput) + sshUsernameInput = findViewById(R.id.sshUsernameInput) + sshPasswordInput = findViewById(R.id.sshPasswordInput) + proxyHostInput = findViewById(R.id.proxyHostInput) + proxyPortInput = findViewById(R.id.proxyPortInput) + payloadInput = findViewById(R.id.payloadInput) + connectButton = findViewById(R.id.connectButton) + disconnectButton = findViewById(R.id.disconnectButton) + statusText = findViewById(R.id.statusText) + logText = findViewById(R.id.logText) + + connectButton.setOnClickListener { + val sshHost = sshHostInput.text.toString().trim() + val sshPort = sshPortInput.text.toString().trim() + val sshUser = sshUsernameInput.text.toString().trim() + val sshPass = sshPasswordInput.text.toString().trim() + val proxyHost = proxyHostInput.text.toString().trim() + val proxyPort = proxyPortInput.text.toString().trim() + val payload = payloadInput.text.toString().trim() + + if (sshHost.isEmpty() || sshPort.isEmpty() || sshUser.isEmpty() || sshPass.isEmpty()) { + addLog("[ERROR] Please fill in SSH details") + statusText.text = "Status: Error - Missing SSH details" + statusText.setTextColor(resources.getColor(android.R.color.holo_red_dark)) + return@setOnClickListener + } + + addLog("[Config] SSH: $sshHost:$sshPort") + addLog("[Config] Proxy: $proxyHost:$proxyPort") + addLog("[Config] Payload: ${if (payload.length > 50) payload.substring(0, 50) + "..." else payload}") + addLog("[INFO] Connect button pressed") + + statusText.text = "Status: Connecting..." + statusText.setTextColor(resources.getColor(android.R.color.holo_orange_dark)) + disconnectButton.isEnabled = true + + startVpnService(sshHost, sshPort, sshUser, sshPass, proxyHost, proxyPort, payload) + } + + disconnectButton.setOnClickListener { + addLog("[INFO] Disconnect button pressed") + stopVpnService() + statusText.text = "Status: Disconnected" + statusText.setTextColor(resources.getColor(android.R.color.holo_red_dark)) + disconnectButton.isEnabled = false + } + } + + private fun startVpnService(sshHost: String, sshPort: String, sshUser: String, sshPass: String, + proxyHost: String, proxyPort: String, payload: String) { + val intent = android.content.Intent(this, CustomVpnService::class.java) + intent.putExtra("sshHost", sshHost) + intent.putExtra("sshPort", sshPort) + intent.putExtra("sshUser", sshUser) + intent.putExtra("sshPass", sshPass) + intent.putExtra("proxyHost", proxyHost) + intent.putExtra("proxyPort", proxyPort) + intent.putExtra("payload", payload) + startService(intent) + } + + private fun stopVpnService() { + val intent = android.content.Intent(this, CustomVpnService::class.java) + stopService(intent) + } + + fun addLog(message: String) { + runOnUiThread { + logText.append("\n$message") + val scrollAmount = logText.layout?.getLineTop(logText.lineCount) ?: 0 + if (scrollAmount > logText.height) { + logText.scrollTo(0, scrollAmount - logText.height) + } + } + } +} diff --git a/app/src/main/res/layout/fragment_config.xml b/app/src/main/res/layout/fragment_config.xml new file mode 100644 index 0000000..8a0222c --- /dev/null +++ b/app/src/main/res/layout/fragment_config.xml @@ -0,0 +1,373 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +