From 42098a4c10496ac94984e643e08463e11bb967fb Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 15:37:23 +0300 Subject: [PATCH 1/2] Add HTTPS with Jump proxy chains --- README.md | 27 ++- .../net/megaproxy487/ConnectionTestScreen.kt | 2 +- .../java/net/megaproxy487/MainActivity.kt | 5 +- .../net/megaproxy487/ProfileEditorScreen.kt | 69 ++++-- .../java/net/megaproxy487/ProfilesScreen.kt | 5 +- .../java/net/megaproxy487/data/ConfigStore.kt | 4 +- .../net/megaproxy487/data/ConfigTransfer.kt | 13 +- .../net/megaproxy487/model/ProxyConfig.kt | 46 ++-- .../java/net/megaproxy487/vpn/ProxyCore.kt | 1 + .../net/megaproxy487/vpn/ProxyVpnService.kt | 10 +- app/src/main/res/values-ru/strings.xml | 29 +++ app/src/main/res/values/strings.xml | 29 +++ .../megaproxy487/data/ConfigTransferTest.kt | 39 ++++ .../net/megaproxy487/model/ProxyConfigTest.kt | 20 ++ docs/en/index.md | 4 + docs/ru/index.md | 18 ++ native/README.md | 3 + native/mobile/config.go | 80 ++++--- native/mobile/dialer.go | 86 +++++-- native/mobile/https_jump_test.go | 221 ++++++++++++++++++ native/mobile/mobile.go | 2 +- native/mobile/test_connection.go | 3 +- 22 files changed, 609 insertions(+), 107 deletions(-) create mode 100644 native/mobile/https_jump_test.go diff --git a/README.md b/README.md index 9b9e6d6..d0b339d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ statistics and diagnostic logs stay on the device unless you explicitly choose t ## Why MegaProxy - **Private by design.** No account, ads, analytics, tracking identifiers, or background telemetry. -- **Your infrastructure.** Connect to HTTPS, SSH, or SSH-with-jump servers that you configure. +- **Your infrastructure.** Connect to your HTTPS or SSH servers, directly or through a jump server. - **End-to-end application encryption.** HTTPS proxying uses CONNECT without intercepting or decrypting application traffic. - **Flexible routing.** Route the whole device or only selected applications through the VPN. @@ -35,7 +35,7 @@ statistics and diagnostic logs stay on the device unless you explicitly choose t ### Connection profiles - Multiple named, colored, reorderable profiles. -- HTTPS proxies over TLS with Basic authentication. +- HTTPS proxies over TLS with Basic authentication, including two-proxy HTTPS with Jump chains. - HTTP/2 CONNECT multiplexing when supported by the proxy, with automatic HTTP/1.1 fallback. - SSH `direct-tcpip` transport and SSH through a jump host. - SSH password and unencrypted private-key authentication. @@ -189,12 +189,33 @@ Server configurations and setup instructions are maintained separately in ### Generated configuration imports External generators can produce MegaProxy JSON files using schema `net.megaproxy487.config`, version -7. Every profile must have a stable, generator-controlled `id`. Reimporting a file updates profiles +8. Every profile must have a stable, generator-controlled `id`. Reimporting a file updates profiles with matching IDs and adds only new IDs; it does not create duplicates. Omitted password and SSH private-key fields preserve credentials already stored on the device, while explicit empty values clear them. After import, MegaProxy offers an unselected list of local profiles absent from the file so the user can optionally remove specific obsolete profiles. +### HTTPS with Jump + +Select **HTTPS with Jump** to use two HTTPS CONNECT proxies in sequence: +phone → jump proxy → destination proxy → website. Enter the destination proxy in the main +connection fields and the first hop in **Jump HTTPS proxy**. Both ports default to 443. +The jump proxy must allow CONNECT to the destination proxy hostname and port; it resolves that +hostname. Only the jump proxy is bootstrapped on the phone. Both hops use the selected TLS +fingerprint and support HTTP/1.1 and HTTP/2 CONNECT independently. + +Each hop verifies its own TLS certificate and can use separate Basic Auth credentials. The +optional shared-authentication setting reuses the destination username and password. Certificate +verification exceptions apply only to the selected hop. Connection tests and DoH use the chain; +local-network destinations still follow the existing bypass setting. A failed hop never causes +fallback to a direct connection to the destination proxy. + +JSON schema version 8 stores this mode as `proxy.type: "HTTPS_JUMP"`, with first-hop settings in +`proxy.jump`: `host`, `port`, `sameAuthentication`, `username`, `password`, and +`allowInvalidProxyCertificate`. Export passwords only when needed. Older application versions +reject version 8 files, preventing a chain from being imported as a single proxy. ProxyList +exports support single HTTPS proxies only and omit chain profiles. + ## Current limitations - Only TCP application traffic is forwarded. General SOCKS5 UDP and QUIC forwarding are not diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt index 318a7b2..4dbf11a 100644 --- a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt @@ -87,7 +87,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack val runTest = { val configStore = ConfigStore(activity) val error = configStore.globalConnectionSettings().applyTo(configStore.activeProfile().config) - .connectionValidationError() + .connectionValidationError()?.let { activity.getString(it) } if (error != null) { TestDiagnosticLog.fail(error) } else { diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index bdcf686..7019e50 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -158,6 +158,7 @@ private fun ProfileTypeBadge(type: ProxyType, foreground: Color) { Text( when (type) { ProxyType.HTTPS -> "HTTPS" + ProxyType.HTTPS_JUMP -> stringResource(R.string.https_with_jump) ProxyType.SSH -> "SSH" ProxyType.SSH_JUMP -> "SSH + Jump" }, @@ -297,7 +298,7 @@ internal fun MainScreen( systemVpnStatus = readAlwaysOnVpnStatus(activity) error = null } else { - error = globalSettings.applyTo(store.activeProfile().config).validationError() + error = globalSettings.applyTo(store.activeProfile().config).validationError()?.let { activity.getString(it) } } if (error == null && !isAlwaysOnVpnActive(activity)) { if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission( @@ -390,7 +391,7 @@ internal fun MainScreen( val displayedProfileId = if (alwaysOn) runtimeProfileId.ifEmpty { connectionProfileId } else activeProfileId val activeProfile = profiles.firstOrNull { it.id == displayedProfileId } ?: profiles.first() val actualProfile = profiles.firstOrNull { it.id == runtimeProfileId } - val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError() + val activeProfileError = globalSettings.applyTo(activeProfile.config).connectionValidationError()?.let { activity.getString(it) } val profileColor = Color(ProfileColors.argb[Math.floorMod(activeProfile.colorIndex, ProfileColors.argb.size)]) val onProfileColor = if (profileColor.luminance() > 0.45f) Color.Black else Color.White Box(Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index 01c0bf7..b666aea 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -90,6 +90,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: var error by remember { mutableStateOf(null) } var countryExpanded by remember { mutableStateOf(false) } var dnsExpanded by remember { mutableStateOf(false) } + var invalidCertificateIsJump by remember { mutableStateOf(false) } var showInvalidCertificateWarning by remember { mutableStateOf(false) } var typeExpanded by remember { mutableStateOf(false) } var unsafeHostKeyHop by remember { mutableStateOf(null) } @@ -116,7 +117,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: config = updated profile = profile.copy(config = updated) saveProfile() - error = globalSettings.applyTo(updated).connectionValidationError() + error = globalSettings.applyTo(updated).connectionValidationError()?.let { activity.getString(it) } if (ProxyVpnService.isRunning && profile.id == editedConnectionProfileId) { coroutineScope.launch(ConfigIoDispatcher) { store.markPendingReconnect() } } @@ -214,46 +215,46 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } item { ExposedDropdownMenuBox(typeExpanded, { typeExpanded = it }) { - OutlinedTextField(config.type.title, {}, readOnly = true, label = { Text(stringResource(R.string.profile_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + OutlinedTextField(if (config.type == ProxyType.HTTPS_JUMP) stringResource(R.string.https_with_jump) else config.type.title, {}, readOnly = true, label = { Text(stringResource(R.string.profile_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) DropdownMenu(typeExpanded, { typeExpanded = false }) { - ProxyType.entries.forEach { type -> DropdownMenuItem(text = { Text(type.title) }, onClick = { - updateConfig(config.copy(type = type, port = type.defaultPort)) + ProxyType.entries.forEach { type -> DropdownMenuItem(text = { Text(if (type == ProxyType.HTTPS_JUMP) stringResource(R.string.https_with_jump) else type.title) }, onClick = { + updateConfig(config.copy(type = type, port = type.defaultPort, jumpPort = if (type.hasJump && (!config.type.hasJump || type.isHttps != config.type.isHttps)) type.defaultPort else config.jumpPort)) portText = type.defaultPort.toString() - if (type == ProxyType.SSH_JUMP) jumpPortText = config.jumpPort.toString() + if (type.hasJump) jumpPortText = config.jumpPort.toString() typeExpanded = false }) } } } } item { - OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { Text(stringResource(if (config.type == ProxyType.HTTPS) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { Text(stringResource(if (config.type == ProxyType.HTTPS_JUMP) R.string.destination_https_proxy_hostname else if (config.type.isHttps) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { OutlinedTextField(portText, { value -> portText = value val port = value.toIntOrNull() - if (port == null) error = "Port must be between 1 and 65535" + if (port == null) error = activity.getString(R.string.validation_port) else updateConfig(config.copy(port = port)) }, label = { Text(stringResource(R.string.port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { Text(stringResource(if (config.type == ProxyType.HTTPS) R.string.basic_auth_username else R.string.ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { Text(stringResource(if (config.type.isHttps) R.string.basic_auth_username else R.string.ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { Text(stringResource(if (config.type == ProxyType.HTTPS) R.string.password else R.string.ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { Text(stringResource(if (config.type.isHttps) R.string.password else R.string.ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } - if (config.type == ProxyType.HTTPS) item { SettingCheckboxRow( + if (config.type.isHttps) item { SettingCheckboxRow( checked = config.allowInvalidProxyCertificate, - title = "Allow self-signed proxy certificate", - description = "Disables certificate verification only for the HTTPS proxy.", + title = stringResource(R.string.allow_proxy_certificate), + description = stringResource(R.string.allow_proxy_certificate_description), onCheckedChange = { checked -> - if (checked) showInvalidCertificateWarning = true + if (checked) { invalidCertificateIsJump = false; showInvalidCertificateWarning = true } else updateConfig(config.copy(allowInvalidProxyCertificate = false)) }, ) } - if (config.type != ProxyType.HTTPS) { + if (!config.type.isHttps) { item { OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it)) } }, label = { Text(stringResource(R.string.private_key_optional)) }, supportingText = { Text(stringResource(R.string.private_key_format_hint)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } @@ -280,6 +281,42 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: if (config.trustedHostKey.isNotBlank()) item { Text(stringResource(R.string.trusted_destination_key, config.trustedHostKey), style = MaterialTheme.typography.bodySmall) } } + if (config.type == ProxyType.HTTPS_JUMP) { + item { Text(stringResource(R.string.https_jump_route), style = MaterialTheme.typography.bodySmall) } + item { HorizontalDivider() } + item { Text(stringResource(R.string.https_jump_proxy), style = MaterialTheme.typography.titleMedium) } + item { + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { Text(stringResource(R.string.https_jump_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(jumpPortText, { value -> + if (value.length <= 5 && value.all(Char::isDigit)) { + jumpPortText = value + updateConfig(config.copy(jumpPort = value.toIntOrNull() ?: 0)) + } + }, label = { Text(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + SettingCheckboxRow(config.sameJumpAuthentication, stringResource(R.string.https_jump_same_auth), stringResource(R.string.https_jump_same_auth_description)) { + updateConfig(config.copy(sameJumpAuthentication = it)) + } + } + if (!config.sameJumpAuthentication) { + item { + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { Text(stringResource(R.string.https_jump_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } + item { + OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { Text(stringResource(R.string.https_jump_password)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + } + } + item { + SettingCheckboxRow(config.jumpAllowInvalidProxyCertificate, stringResource(R.string.allow_jump_proxy_certificate), stringResource(R.string.allow_jump_proxy_certificate_description)) { checked -> + if (checked) { invalidCertificateIsJump = true; showInvalidCertificateWarning = true } + else updateConfig(config.copy(jumpAllowInvalidProxyCertificate = false)) + } + } + } + if (config.type == ProxyType.SSH_JUMP) { item { HorizontalDivider() @@ -396,9 +433,9 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: AlertDialog( onDismissRequest = { showInvalidCertificateWarning = false }, title = { Text(stringResource(R.string.allow_untrusted_certificate_title)) }, - text = { Text(stringResource(R.string.allow_untrusted_certificate_message)) }, + text = { Text(stringResource(if (invalidCertificateIsJump) R.string.allow_untrusted_jump_certificate_message else R.string.allow_untrusted_certificate_message)) }, confirmButton = { TextButton(onClick = { - updateConfig(config.copy(allowInvalidProxyCertificate = true)) + updateConfig(if (invalidCertificateIsJump) config.copy(jumpAllowInvalidProxyCertificate = true) else config.copy(allowInvalidProxyCertificate = true)) showInvalidCertificateWarning = false }) { Text(stringResource(R.string.ok)) } }, dismissButton = { TextButton(onClick = { showInvalidCertificateWarning = false }) { Text(stringResource(R.string.cancel)) } }, diff --git a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt index d64ab4a..2f25be0 100644 --- a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -120,6 +120,7 @@ private fun profileOptionGroups(current: ProxyProfile, imported: ProxyProfile): current.config.sameJumpAuthentication != imported.config.sameJumpAuthentication ) add(R.string.import_option_connection) if (current.config.allowInvalidProxyCertificate != imported.config.allowInvalidProxyCertificate || + current.config.jumpAllowInvalidProxyCertificate != imported.config.jumpAllowInvalidProxyCertificate || current.config.profile != imported.config.profile || current.config.customJa3 != imported.config.customJa3 || current.config.sshProfile != imported.config.sshProfile || current.config.trustedHostKey != imported.config.trustedHostKey || @@ -171,6 +172,7 @@ private fun applySelectedProfileOptions( )) if (selected(R.string.import_option_security)) result = result.copy(config = result.config.copy( allowInvalidProxyCertificate = imported.config.allowInvalidProxyCertificate, + jumpAllowInvalidProxyCertificate = imported.config.jumpAllowInvalidProxyCertificate, profile = imported.config.profile, customJa3 = imported.config.customJa3, sshProfile = imported.config.sshProfile, @@ -357,7 +359,7 @@ internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfil }.getOrDefault(false) if (isMegaProxy) { val configuration = ConfigTransfer.importJson(text) - if (configuration.profiles.any { it.config.allowInvalidProxyCertificate || it.config.acceptAnyHostKey || it.config.jumpAcceptAnyHostKey }) { + if (configuration.profiles.any { it.config.allowInvalidProxyCertificate || it.config.jumpAllowInvalidProxyCertificate || it.config.acceptAnyHostKey || it.config.jumpAcceptAnyHostKey }) { pendingUnsafeImport = configuration } else { prepareJsonImport(configuration) @@ -799,6 +801,7 @@ private fun ProfileCard( Text( when (profile.config.type) { ProxyType.HTTPS -> "HTTPS" + ProxyType.HTTPS_JUMP -> stringResource(R.string.https_with_jump) ProxyType.SSH -> "SSH" ProxyType.SSH_JUMP -> "SSH + Jump" }, diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index 96dee89..348745f 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -465,6 +465,7 @@ class ConfigStore(context: Context) { put("jumpPassword", encrypt(config.jumpPassword)) put("jumpPrivateKey", encrypt(config.jumpPrivateKey)) put("jumpTrustedHostKey", config.jumpTrustedHostKey) + put("jumpAllowInvalidProxyCertificate", config.jumpAllowInvalidProxyCertificate) put("jumpAcceptAnyHostKey", config.jumpAcceptAnyHostKey) put("sameJumpAuthentication", config.sameJumpAuthentication) put("allowInvalidProxyCertificate", config.allowInvalidProxyCertificate) @@ -503,11 +504,12 @@ class ConfigStore(context: Context) { trustedHostKey = item.optString("trustedHostKey"), acceptAnyHostKey = item.optBoolean("acceptAnyHostKey", false), jumpHost = item.optString("jumpHost"), - jumpPort = item.optInt("jumpPort", 22), + jumpPort = item.optInt("jumpPort", enumValue(item.optString("type"), ProxyType.HTTPS).defaultPort), jumpUsername = item.optString("jumpUsername"), jumpPassword = decrypt(item.optString("jumpPassword").ifEmpty { null }), jumpPrivateKey = decrypt(item.optString("jumpPrivateKey").ifEmpty { null }), jumpTrustedHostKey = item.optString("jumpTrustedHostKey"), + jumpAllowInvalidProxyCertificate = item.optBoolean("jumpAllowInvalidProxyCertificate", false), jumpAcceptAnyHostKey = item.optBoolean("jumpAcceptAnyHostKey", false), sameJumpAuthentication = item.optBoolean("sameJumpAuthentication", true), allowInvalidProxyCertificate = item.optBoolean("allowInvalidProxyCertificate", false), diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index c283f27..c4e849c 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -39,7 +39,7 @@ data class ProfileSecretPresence( object ConfigTransfer { const val SCHEMA_ID = "net.megaproxy487.config" const val LEGACY_SCHEMA_ID = "dev.megaproxy.config" - const val SCHEMA_VERSION = 7 + const val SCHEMA_VERSION = 8 fun isSupportedSchema(value: String): Boolean = value == SCHEMA_ID || value == LEGACY_SCHEMA_ID @@ -177,7 +177,7 @@ object ConfigTransfer { ) } - private fun encodeProfile(profile: ProxyProfile, includePasswords: Boolean, includePrivateKeys: Boolean) = JSONObject().apply { + internal fun encodeProfile(profile: ProxyProfile, includePasswords: Boolean, includePrivateKeys: Boolean) = JSONObject().apply { put("id", profile.id) put("name", profile.name.trim()) put("color", profile.colorIndex) @@ -193,7 +193,7 @@ object ConfigTransfer { put("sshProfile", profile.config.sshProfile.name) put("trustedHostKey", profile.config.trustedHostKey) put("acceptAnyHostKey", profile.config.acceptAnyHostKey) - if (profile.config.type == ProxyType.SSH_JUMP) put("jump", JSONObject().apply { + if (profile.config.type.hasJump) put("jump", JSONObject().apply { put("host", profile.config.jumpHost) put("port", profile.config.jumpPort) put("sameAuthentication", profile.config.sameJumpAuthentication) @@ -204,6 +204,7 @@ object ConfigTransfer { } put("trustedHostKey", profile.config.jumpTrustedHostKey) put("acceptAnyHostKey", profile.config.jumpAcceptAnyHostKey) + put("allowInvalidProxyCertificate", profile.config.jumpAllowInvalidProxyCertificate) }) }) put("tls", JSONObject().apply { @@ -229,6 +230,7 @@ object ConfigTransfer { val routing = item.optJSONObject("routing") ?: JSONObject() val host = proxy.limitedString("host", 253).trim() require(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) + val type = enumValue(proxy.optString("type"), ProxyType.HTTPS) val jump = proxy.optJSONObject("jump") val packages = routing.optJSONArray("selectedPackages")?.let { array -> require(array.length() <= MAX_IMPORTED_PACKAGES) { "A profile contains too many application package names" } @@ -244,7 +246,7 @@ object ConfigTransfer { countryCode = item.limitedString("countryCode", 2).uppercase() .takeIf { it.matches(Regex("[A-Z]{2}")) }.orEmpty(), config = ProxyConfig( - type = enumValue(proxy.optString("type"), ProxyType.HTTPS), + type = type, host = host, port = proxy.optInt("port", 443).takeIf { it in 1..65535 } ?: 443, username = proxy.limitedString("username", 4_096), @@ -254,12 +256,13 @@ object ConfigTransfer { trustedHostKey = proxy.limitedString("trustedHostKey", 256), acceptAnyHostKey = proxy.optBoolean("acceptAnyHostKey", false), jumpHost = jump?.limitedString("host", 253).orEmpty(), - jumpPort = jump?.optInt("port", 22) ?: 22, + jumpPort = jump?.optInt("port", type.defaultPort) ?: type.defaultPort, sameJumpAuthentication = jump?.optBoolean("sameAuthentication", true) ?: true, jumpUsername = jump?.limitedString("username", 4_096).orEmpty(), jumpPassword = jump?.limitedString("password", 16_384).orEmpty(), jumpPrivateKey = jump?.limitedString("privateKey", 64 * 1024).orEmpty(), jumpTrustedHostKey = jump?.limitedString("trustedHostKey", 256).orEmpty(), + jumpAllowInvalidProxyCertificate = jump?.optBoolean("allowInvalidProxyCertificate", false) ?: false, jumpAcceptAnyHostKey = jump?.optBoolean("acceptAnyHostKey", false) ?: false, allowInvalidProxyCertificate = proxy.optBoolean("allowInvalidProxyCertificate", false), profile = enumValue(tls.optString("fingerprint"), TlsProfile.DEFAULT), diff --git a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt index f6c0a19..a3b419d 100644 --- a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt +++ b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt @@ -1,5 +1,8 @@ package net.megaproxy487.model +import androidx.annotation.StringRes +import net.megaproxy487.R + data class ProxyConfig( val type: ProxyType = ProxyType.HTTPS, val host: String = "", @@ -21,11 +24,12 @@ data class ProxyConfig( val trustedHostKey: String = "", val acceptAnyHostKey: Boolean = false, val jumpHost: String = "", - val jumpPort: Int = 22, + val jumpPort: Int = type.defaultPort, val jumpUsername: String = "", val jumpPassword: String = "", val jumpPrivateKey: String = "", val jumpTrustedHostKey: String = "", + val jumpAllowInvalidProxyCertificate: Boolean = false, val jumpAcceptAnyHostKey: Boolean = false, val sameJumpAuthentication: Boolean = true, val resolvedJumpIp: String = "", @@ -35,31 +39,39 @@ data class ProxyConfig( val sshRotationMinutes: Int = 0, val sshRotationMb: Int = 0, ) { - fun connectionValidationError(): String? = when { - host.isBlank() -> if (type == ProxyType.HTTPS) "Enter the proxy hostname" else "Enter the SSH hostname" - host.contains(Regex("[/:\\s]")) -> "Enter a hostname without a scheme or path" - port !in 1..65535 -> "Port must be between 1 and 65535" - type == ProxyType.HTTPS && username.isBlank() -> "Enter the Basic Auth username" - type == ProxyType.HTTPS && password.isBlank() -> "Enter the Basic Auth password" - type != ProxyType.HTTPS && username.isBlank() -> "Enter the SSH username" - type == ProxyType.SSH_JUMP && jumpHost.isBlank() -> "Enter the jump host" - type == ProxyType.SSH_JUMP && jumpHost.contains(Regex("[/:\\s]")) -> "Enter a jump hostname without a scheme or path" - type == ProxyType.SSH_JUMP && jumpPort !in 1..65535 -> "Jump port must be between 1 and 65535" - type == ProxyType.SSH_JUMP && !sameJumpAuthentication && jumpUsername.isBlank() -> "Enter the jump SSH username" - type == ProxyType.HTTPS && profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null -> - "JA3 must contain five fields: version,ciphers,extensions,groups,points" + @StringRes + fun connectionValidationError(): Int? = when { + host.isBlank() -> if (type.isHttps) R.string.validation_proxy_host else R.string.validation_ssh_host + host.contains(Regex("[/:\\s]")) -> R.string.validation_host_format + port !in 1..65535 -> R.string.validation_port + type.isHttps && username.isBlank() -> R.string.validation_basic_username + type.isHttps && password.isBlank() -> R.string.validation_basic_password + !type.isHttps && username.isBlank() -> R.string.validation_ssh_username + type.hasJump && jumpHost.isBlank() -> R.string.validation_jump_host + type.hasJump && jumpHost.contains(Regex("[/:\\s]")) -> R.string.validation_jump_host_format + type.hasJump && jumpPort !in 1..65535 -> R.string.validation_jump_port + type == ProxyType.SSH_JUMP && !sameJumpAuthentication && jumpUsername.isBlank() -> R.string.validation_jump_ssh_username + type == ProxyType.HTTPS_JUMP && !sameJumpAuthentication && jumpUsername.isBlank() -> R.string.validation_jump_basic_username + type == ProxyType.HTTPS_JUMP && !sameJumpAuthentication && jumpPassword.isBlank() -> R.string.validation_jump_basic_password + type.isHttps && profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null -> + R.string.validation_ja3 dnsProvider == DnsProvider.CUSTOM && !customDohUrl.matches(Regex("https://[^/\\s]+/.+")) -> - "The custom DoH URL must start with https://" + R.string.validation_doh_url else -> null } - fun validationError(): String? = connectionValidationError() + @StringRes + fun validationError(): Int? = connectionValidationError() } enum class ProxyType(val title: String, val defaultPort: Int) { HTTPS("HTTPS", 443), + HTTPS_JUMP("HTTPS with Jump", 443), SSH("SSH", 22), - SSH_JUMP("SSH with Jump", 22), + SSH_JUMP("SSH with Jump", 22); + + val isHttps: Boolean get() = this == HTTPS || this == HTTPS_JUMP + val hasJump: Boolean get() = this == HTTPS_JUMP || this == SSH_JUMP } enum class SshProfile(val title: String) { diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt index 2530b89..667338c 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt @@ -75,6 +75,7 @@ class NativeProxyCore( .put("jumpPassword", config.jumpPassword) .put("jumpPrivateKey", config.jumpPrivateKey) .put("jumpTrustedHostKey", config.jumpTrustedHostKey) + .put("jumpAllowInvalidProxyCertificate", config.jumpAllowInvalidProxyCertificate) .put("jumpAcceptAnyHostKey", config.jumpAcceptAnyHostKey) .put("sameJumpAuthentication", config.sameJumpAuthentication) .put("sshAuthMode", config.sshAuthMode.name) diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index be1443b..2ea41a5 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -241,7 +241,7 @@ class ProxyVpnService : VpnService() { private fun testConnection() { val storedConfig = ConfigStore(this).globalConnectionSettings().applyTo(ConfigStore(this).activeProfile().config) - storedConfig.connectionValidationError()?.let { + storedConfig.connectionValidationError()?.let { getString(it) }?.let { TestDiagnosticLog.fail("Connection test cannot start: $it") if (tunnel == null) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() } return @@ -287,7 +287,7 @@ class ProxyVpnService : VpnService() { else if (storedConfig.routeAllApps) "event=vpn_start mode=global" else "event=vpn_start mode=split selected_app_count=${storedConfig.selectedPackages.size}" ) - val validationError = if (testOnly) storedConfig.connectionValidationError() else storedConfig.validationError() + val validationError = if (testOnly) storedConfig.connectionValidationError()?.let { getString(it) } else storedConfig.validationError()?.let { getString(it) } validationError?.let { if (testOnly) TestDiagnosticLog.fail(it) else { val notice = if (isAlwaysOnMode) @@ -353,13 +353,13 @@ class ProxyVpnService : VpnService() { getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(message)) }?.also { addressCache.put(host, it) } } - val proxyIp = resolveHost(storedConfig.host, "proxy") ?: run { + val proxyIp = if (storedConfig.type == net.megaproxy487.model.ProxyType.HTTPS_JUMP) "" else resolveHost(storedConfig.host, "proxy") ?: run { establishedTunnel.close() if (!isStartCurrent(generation)) return false handleStartFailure(testOnly, "Proxy bootstrap DNS failed", failureDetail, promptProfileId) return false } - val jumpIp = if (storedConfig.type == net.megaproxy487.model.ProxyType.SSH_JUMP) { + val jumpIp = if (storedConfig.type.hasJump) { resolveHost(storedConfig.jumpHost, "jump") ?: run { establishedTunnel.close() if (!isStartCurrent(generation)) return false @@ -525,7 +525,7 @@ class ProxyVpnService : VpnService() { store.profile(profileId)?.config?.let { profile -> BootstrapAddressCache(this).apply { remove(profile.host) - if (profile.type == net.megaproxy487.model.ProxyType.SSH_JUMP) remove(profile.jumpHost) + if (profile.type.hasJump) remove(profile.jumpHost) } } val notice = "$warning Retrying this profile with fresh encrypted DNS before failover." diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 77a50df..d1ac730 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -248,4 +248,33 @@ %1$d минут назад %1$d минуты назад + HTTPS через Jump + Телефон → промежуточный HTTPS-прокси → конечный HTTPS-прокси → сайт. + Промежуточный HTTPS-прокси (первое звено) + Имя промежуточного HTTPS-прокси + Имя пользователя Basic Auth промежуточного прокси + Пароль Basic Auth промежуточного прокси + Использовать те же данные входа + Использовать имя пользователя и пароль конечного прокси. + Разрешить самоподписанный сертификат прокси + Отключает проверку сертификата только конечного HTTPS-прокси. + Разрешить самоподписанный сертификат промежуточного прокси + Отключает проверку сертификата только промежуточного HTTPS-прокси. + Это отключает проверку цепочки сертификатов и имени промежуточного HTTPS-прокси. Злоумышленник сможет выдать себя за него и получить его данные Basic Auth. Проверка сертификата конечного прокси определяется отдельной настройкой. + Имя конечного HTTPS-прокси + Введите имя прокси + Введите имя SSH-сервера + Введите имя хоста без схемы и пути + Порт должен быть от 1 до 65535 + Введите имя пользователя Basic Auth + Введите пароль Basic Auth + Введите имя пользователя SSH + Введите имя промежуточного сервера + Введите имя промежуточного сервера без схемы и пути + Порт промежуточного сервера должен быть от 1 до 65535 + Введите имя пользователя промежуточного SSH-сервера + JA3 должен содержать пять полей: version,ciphers,extensions,groups,points + Пользовательский URL DoH должен начинаться с https:// + Введите имя пользователя Basic Auth промежуточного прокси + Введите пароль Basic Auth промежуточного прокси diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5851dbb..fffce0d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -243,4 +243,33 @@ %1$d min ago %1$d min ago + HTTPS with Jump + Phone → jump HTTPS proxy → destination HTTPS proxy → website. + Jump HTTPS proxy (first hop) + Jump HTTPS proxy hostname + Jump Basic Auth username + Jump Basic Auth password + Use the same authentication + Reuse the destination proxy username and password. + Allow self-signed proxy certificate + Disables certificate verification only for the destination HTTPS proxy. + Allow self-signed jump proxy certificate + Disables certificate verification only for the jump HTTPS proxy. + This disables certificate-chain and hostname verification for the jump HTTPS proxy. An attacker could impersonate it and obtain its Basic Auth credentials. The destination proxy certificate remains subject to its own setting. + Destination HTTPS proxy hostname + Enter the proxy hostname + Enter the SSH hostname + Enter a hostname without a scheme or path + Port must be between 1 and 65535 + Enter the Basic Auth username + Enter the Basic Auth password + Enter the SSH username + Enter the jump host + Enter a jump hostname without a scheme or path + Jump port must be between 1 and 65535 + Enter the jump SSH username + JA3 must contain five fields: version,ciphers,extensions,groups,points + The custom DoH URL must start with https:// + Enter the jump Basic Auth username + Enter the jump Basic Auth password diff --git a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt index b641ce4..6c1a63b 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt @@ -1,6 +1,9 @@ package net.megaproxy487.data import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyType +import org.json.JSONArray +import org.json.JSONObject import net.megaproxy487.model.ProxyProfile import net.megaproxy487.model.TlsProfile import org.junit.Assert.assertEquals @@ -9,6 +12,42 @@ import org.junit.Assert.assertTrue import org.junit.Test class ConfigTransferTest { + @Test + fun `HTTPS jump JSON round trip preserves both hops and certificate settings`() { + val profile = ProxyProfile(id = "chain", colorIndex = 0, config = ProxyConfig( + type = ProxyType.HTTPS_JUMP, host = "exit.example", username = "exit", password = "exit-secret", + jumpHost = "jump.example", jumpPort = 8443, sameJumpAuthentication = false, + jumpUsername = "jump", jumpPassword = "jump-secret", jumpAllowInvalidProxyCertificate = true, + )) + for (includePasswords in listOf(false, true)) { + val encoded = ConfigTransfer.encodeProfile(profile, includePasswords, false) + val raw = JSONObject().put("schema", ConfigTransfer.SCHEMA_ID) + .put("version", ConfigTransfer.SCHEMA_VERSION).put("profiles", JSONArray().put(encoded)).toString() + val decoded = ConfigTransfer.importJson(raw) + assertEquals(profile.config.copy( + password = if (includePasswords) "exit-secret" else "", + jumpPassword = if (includePasswords) "jump-secret" else "", + ), decoded.profiles.single().config) + assertEquals(includePasswords, decoded.secretPresence.getValue("chain").jumpPassword) + assertEquals(includePasswords, raw.contains("jump-secret")) + assertEquals(includePasswords, raw.contains("exit-secret")) + } + assertTrue(ConfigTransfer.SCHEMA_VERSION > 7) + assertTrue(ConfigTransfer.exportProxyList(listOf(profile), true).isBlank()) + } + + @Test + fun `HTTPS jump import defaults to port 443 and verified certificates`() { + val raw = """{"schema":"net.megaproxy487.config","version":8,"profiles":[ + {"id":"chain","proxy":{"type":"HTTPS_JUMP","host":"exit.example","jump":{"host":"jump.example"}}} + ]}""" + val config = ConfigTransfer.importJson(raw).profiles.single().config + assertEquals(443, config.jumpPort) + assertTrue(config.sameJumpAuthentication) + assertFalse(config.allowInvalidProxyCertificate) + assertFalse(config.jumpAllowInvalidProxyCertificate) + } + @Test fun `proxy list omits passwords by default`() { val profile = ProxyProfile( diff --git a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt index e030249..cc2f5e2 100644 --- a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt +++ b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt @@ -1,5 +1,6 @@ package net.megaproxy487.model +import net.megaproxy487.R import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -26,6 +27,25 @@ class ProxyConfigTest { password = "password", ) + @Test + fun `HTTPS jump validates both proxies and supports shared credentials`() { + val chain = validConnection.copy(type = ProxyType.HTTPS_JUMP, jumpHost = "jump.example", jumpPort = 443) + assertNull(chain.validationError()) + assertEquals(R.string.validation_jump_host, chain.copy(jumpHost = "").validationError()) + assertEquals(R.string.validation_jump_port, chain.copy(jumpPort = 0).validationError()) + assertEquals(R.string.validation_jump_basic_username, chain.copy(sameJumpAuthentication = false).validationError()) + assertEquals(R.string.validation_jump_basic_password, chain.copy(sameJumpAuthentication = false, jumpUsername = "jump").validationError()) + assertNull(chain.copy(sameJumpAuthentication = false, jumpUsername = "jump", jumpPassword = "secret").validationError()) + assertEquals(443, ProxyConfig(type = ProxyType.HTTPS_JUMP).jumpPort) + assertEquals(22, ProxyConfig(type = ProxyType.SSH_JUMP).jumpPort) + } + + @Test + fun `proxy transport families include both jump modes`() { + assertEquals(setOf(ProxyType.HTTPS, ProxyType.HTTPS_JUMP), ProxyType.entries.filter { it.isHttps }.toSet()) + assertEquals(setOf(ProxyType.SSH_JUMP, ProxyType.HTTPS_JUMP), ProxyType.entries.filter { it.hasJump }.toSet()) + } + @Test fun splitTunnelingAllowsNoApplications() { assertNull(validConnection.copy(routeAllApps = false).validationError()) diff --git a/docs/en/index.md b/docs/en/index.md index 666b99a..b9d5b81 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -3,6 +3,10 @@ Server configurations and setup instructions have moved to the dedicated [MegaProxyServer repository](https://github.com/andre487/MegaProxyServer). +Client connections: + +- [HTTPS with Jump](../../README.md#https-with-jump) + Developer documentation: - [Fastlane workflow](fastlane.md) diff --git a/docs/ru/index.md b/docs/ru/index.md index 0e267d0..892d0e8 100644 --- a/docs/ru/index.md +++ b/docs/ru/index.md @@ -8,3 +8,21 @@ - [Работа с Fastlane](fastlane.md) [English version](../en/index.md) + +## HTTPS через Jump + +Режим **HTTPS через Jump** соединяет два обычных HTTPS CONNECT-прокси: +телефон → промежуточный прокси → конечный прокси → сайт. В основных полях укажите конечный +прокси, а в разделе промежуточного — первое звено. Порт по умолчанию для обоих — 443. +Промежуточный прокси должен разрешать CONNECT к имени и порту конечного прокси; он же разрешает +его имя. На телефоне разрешается только имя промежуточного прокси. + +Оба звена используют выбранный TLS-профиль и независимо поддерживают HTTP/1.1 и HTTP/2 CONNECT. +Проверка сертификата и данные Basic Auth настраиваются отдельно для каждого звена; можно +использовать одинаковые имя пользователя и пароль. Проверка подключения и DoH идут через +цепочку. Доступ к локальной сети определяется существующей настройкой обхода. При отказе +звена приложение не подключается к конечному прокси напрямую. + +Цепочки сохраняются в JSON версии 8 (`proxy.type: "HTTPS_JUMP"`, первое звено — `proxy.jump`). +Старые версии приложения отклоняют этот формат, чтобы не превратить цепочку в одиночный прокси. +Экспорт ProxyList пропускает цепочки, поскольку поддерживает только одиночные HTTPS-прокси. diff --git a/native/README.md b/native/README.md index 8f94692..9767634 100644 --- a/native/README.md +++ b/native/README.md @@ -3,6 +3,9 @@ This module is bound into `megaproxy.aar` with gomobile. It embeds tun2socks/gVisor, registers HTTPS CONNECT and SSH `direct-tcpip` transports, protects every upstream socket through Android `VpnService.protect`, verifies HTTPS proxy certificates and SSH host keys, and uses uTLS for HTTPS ClientHello control. +HTTPS with Jump nests a second HTTPS CONNECT transport inside the first, with independent TLS +verification, authentication and HTTP/2 sessions. Only the jump is dialed directly; it resolves +the destination proxy hostname. Application traffic counters exclude the intermediate tunnel. SSH with Jump creates a nested SSH client through the jump session. SSH transports support TCP; DNS is carried over DoH, while arbitrary UDP (including QUIC) is intentionally blocked. `Start` always takes ownership of the passed duplicate TUN descriptor, including error paths. diff --git a/native/mobile/config.go b/native/mobile/config.go index 54a7c18..c333fc2 100644 --- a/native/mobile/config.go +++ b/native/mobile/config.go @@ -12,37 +12,38 @@ import ( ) type config struct { - Type string `json:"type"` - Host string `json:"host"` - DialHost string `json:"dialHost"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - AllowInvalidProxyCertificate bool `json:"allowInvalidProxyCertificate"` - Profile string `json:"profile"` - CustomJA3 string `json:"customJa3"` - DoHURL string `json:"dohUrl"` - DoHFallbackURLs []string `json:"dohFallbackUrls"` - AllowIPv6 bool `json:"allowIpv6"` - BypassLocalNetworks bool `json:"bypassLocalNetworks"` - PrivateKey string `json:"privateKey"` - SSHProfile string `json:"sshProfile"` - TrustedHostKey string `json:"trustedHostKey"` - AcceptAnyHostKey bool `json:"acceptAnyHostKey"` - JumpHost string `json:"jumpHost"` - JumpDialHost string `json:"jumpDialHost"` - JumpPort int `json:"jumpPort"` - JumpUsername string `json:"jumpUsername"` - JumpPassword string `json:"jumpPassword"` - JumpPrivateKey string `json:"jumpPrivateKey"` - JumpTrustedHostKey string `json:"jumpTrustedHostKey"` - JumpAcceptAnyHostKey bool `json:"jumpAcceptAnyHostKey"` - SameJumpAuthentication bool `json:"sameJumpAuthentication"` - SSHAuthMode string `json:"sshAuthMode"` - SSHKeepaliveSeconds int `json:"sshKeepaliveSeconds"` - SSHMaxChannels int `json:"sshMaxChannels"` - SSHRotationMinutes int `json:"sshRotationMinutes"` - SSHRotationMB int `json:"sshRotationMb"` + Type string `json:"type"` + Host string `json:"host"` + DialHost string `json:"dialHost"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + AllowInvalidProxyCertificate bool `json:"allowInvalidProxyCertificate"` + Profile string `json:"profile"` + CustomJA3 string `json:"customJa3"` + DoHURL string `json:"dohUrl"` + DoHFallbackURLs []string `json:"dohFallbackUrls"` + AllowIPv6 bool `json:"allowIpv6"` + BypassLocalNetworks bool `json:"bypassLocalNetworks"` + PrivateKey string `json:"privateKey"` + SSHProfile string `json:"sshProfile"` + TrustedHostKey string `json:"trustedHostKey"` + AcceptAnyHostKey bool `json:"acceptAnyHostKey"` + JumpHost string `json:"jumpHost"` + JumpDialHost string `json:"jumpDialHost"` + JumpPort int `json:"jumpPort"` + JumpUsername string `json:"jumpUsername"` + JumpPassword string `json:"jumpPassword"` + JumpPrivateKey string `json:"jumpPrivateKey"` + JumpTrustedHostKey string `json:"jumpTrustedHostKey"` + JumpAllowInvalidProxyCertificate bool `json:"jumpAllowInvalidProxyCertificate"` + JumpAcceptAnyHostKey bool `json:"jumpAcceptAnyHostKey"` + SameJumpAuthentication bool `json:"sameJumpAuthentication"` + SSHAuthMode string `json:"sshAuthMode"` + SSHKeepaliveSeconds int `json:"sshKeepaliveSeconds"` + SSHMaxChannels int `json:"sshMaxChannels"` + SSHRotationMinutes int `json:"sshRotationMinutes"` + SSHRotationMB int `json:"sshRotationMb"` } func parseConfig(raw string) (config, error) { @@ -58,19 +59,19 @@ func parseConfig(raw string) (config, error) { if c.Host == "" || strings.ContainsAny(c.Host, "/: \t\r\n") { return c, errors.New("invalid proxy hostname") } - if net.ParseIP(c.DialHost) == nil { + if c.Type != "HTTPS_JUMP" && net.ParseIP(c.DialHost) == nil { return c, errors.New("proxy bootstrap IP is missing or invalid") } if c.Port < 1 || c.Port > 65535 { return c, errors.New("invalid proxy port") } - if c.Type == "HTTPS" && (c.Username == "" || c.Password == "") { + if c.isHTTPS() && (c.Username == "" || c.Password == "") { return c, errors.New("basic auth credentials are required") } - if c.Type != "HTTPS" && c.Username == "" { + if !c.isHTTPS() && c.Username == "" { return c, errors.New("SSH username is required") } - if c.Type == "SSH_JUMP" { + if c.Type == "SSH_JUMP" || c.Type == "HTTPS_JUMP" { if c.JumpHost == "" || strings.ContainsAny(c.JumpHost, "/: \t\r\n") { return c, errors.New("invalid jump hostname") } @@ -83,6 +84,9 @@ func parseConfig(raw string) (config, error) { if c.SameJumpAuthentication { c.JumpUsername, c.JumpPassword, c.JumpPrivateKey = c.Username, c.Password, c.PrivateKey } + if c.Type == "HTTPS_JUMP" && (c.JumpUsername == "" || c.JumpPassword == "") { + return c, errors.New("jump basic auth credentials are required") + } if c.JumpUsername == "" { return c, errors.New("jump SSH username is required") } @@ -95,12 +99,12 @@ func parseConfig(raw string) (config, error) { return c, fmt.Errorf("invalid fallback DoH URL: %w", err) } } - if c.Type == "HTTPS" { + if c.isHTTPS() { if _, err := c.helloID(); err != nil { return c, err } } - if c.Type != "HTTPS" && c.Type != "SSH" && c.Type != "SSH_JUMP" { + if !c.isHTTPS() && c.Type != "SSH" && c.Type != "SSH_JUMP" { return c, fmt.Errorf("unsupported proxy type %q", c.Type) } if c.SSHAuthMode == "" { @@ -118,6 +122,8 @@ func parseConfig(raw string) (config, error) { return c, nil } +func (c config) isHTTPS() bool { return c.Type == "HTTPS" || c.Type == "HTTPS_JUMP" } + func (c config) address() string { return net.JoinHostPort(c.DialHost, strconv.Itoa(c.Port)) } func (c config) displayAddress() string { return net.JoinHostPort(c.Host, strconv.Itoa(c.Port)) } diff --git a/native/mobile/dialer.go b/native/mobile/dialer.go index ea774e7..79e96b9 100644 --- a/native/mobile/dialer.go +++ b/native/mobile/dialer.go @@ -25,6 +25,8 @@ var errUDPBlocked = errors.New("UDP is intentionally blocked") type Protector interface{ Protect(fd int) bool } type httpsConnectDialer struct { + jump *httpsConnectDialer + intermediate bool config config protector Protector reporter Reporter @@ -40,6 +42,9 @@ type httpsConnectDialer struct { func (d *httpsConnectDialer) Close() error { d.cacheMu.Lock() defer d.cacheMu.Unlock() + if d.jump != nil { + _ = d.jump.Close() + } if d.dohClient != nil { d.dohClient.CloseIdleConnections() d.dohClient = nil @@ -111,14 +116,14 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( } report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=started fingerprint=%s", connectionID, d.config.Profile) dialStarted := time.Now() - raw, err := d.protectedDialer().DialContext(ctx, "tcp", d.config.address()) + raw, err := d.dialProxy(ctx) if err != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) err = fmt.Errorf("dial HTTPS proxy: %w", err) report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=failed reason=%s elapsed_ms=%d", connectionID, errorClass(err), time.Since(dialStarted).Milliseconds()) return nil, err } - recordProxyLatency(time.Since(dialStarted)) + d.recordLatency(time.Since(dialStarted)) report(d.reporter, "event=connection conn=%d mode=proxy stage=tcp_connect result=success elapsed_ms=%d", connectionID, time.Since(dialStarted).Milliseconds()) closeOnError := true defer func() { @@ -129,7 +134,7 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( hello, err := d.config.helloID() if err != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) report(d.reporter, "event=connection conn=%d mode=proxy stage=fingerprint result=failed reason=invalid_configuration", connectionID) return nil, err } @@ -141,14 +146,14 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( }, hello) if hello == tls.HelloCustom { if err := applyJA3(uconn, d.config.CustomJA3, d.config.Host); err != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) report(d.reporter, "event=connection conn=%d mode=proxy stage=fingerprint result=failed reason=invalid_ja3", connectionID) return nil, err } } if d.isHTTP2Disabled() { if err := forceHTTP11ALPN(uconn); err != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) return nil, fmt.Errorf("configure HTTP/1.1 ALPN fallback: %w", err) } } @@ -157,7 +162,7 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( err = uconn.HandshakeContext(handshakeContext) cancelHandshake() if err != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) reason := errorClass(err) report(d.reporter, "event=connection conn=%d mode=proxy stage=tls_handshake result=failed reason=%s elapsed_ms=%d dpi_hint=%s fingerprint=%s", connectionID, reason, time.Since(tlsStarted).Milliseconds(), tlsInterferenceHint(reason), d.config.Profile) err = fmt.Errorf("TLS handshake with proxy: %w", err) @@ -170,7 +175,7 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( if h2Negotiated { session, sessionErr := newHTTP2ConnectSession(uconn) if sessionErr != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) return nil, fmt.Errorf("initialize HTTP/2 proxy session: %w", sessionErr) } closeOnError = false // The HTTP/2 session now owns the outer TLS connection. @@ -193,7 +198,7 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( auth := base64.StdEncoding.EncodeToString([]byte(d.config.Username + ":" + d.config.Password)) if _, err := fmt.Fprintf(uconn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: Basic %s\r\nProxy-Connection: Keep-Alive\r\n\r\n", target, target, auth); err != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) err = fmt.Errorf("write CONNECT: %w", err) report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_write result=failed reason=%s", connectionID, errorClass(err)) return nil, err @@ -203,14 +208,14 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( reader := bufio.NewReader(&limitedHeaderReader{reader: uconn, remaining: 64 * 1024}) response, err := http.ReadResponse(reader, &http.Request{Method: http.MethodConnect}) if err != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) err = fmt.Errorf("read CONNECT response: %w", err) reason := errorClass(err) report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_response result=failed reason=%s dpi_hint=%s", connectionID, reason, tlsInterferenceHint(reason)) return nil, err } if response.StatusCode != http.StatusOK { - recordConnectionOutcome(false) + d.recordOutcome(false) _ = response.Body.Close() err = fmt.Errorf("proxy CONNECT returned %s", response.Status) report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_response result=rejected status=%d status_class=%dxx", connectionID, response.StatusCode, response.StatusCode/100) @@ -219,14 +224,61 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( if err := uconn.SetDeadline(time.Time{}); err != nil { return nil, fmt.Errorf("clear CONNECT deadline: %w", err) } - recordConnectionOutcome(true) + d.recordOutcome(true) report(d.reporter, "event=connection conn=%d mode=proxy stage=tunnel result=established total_ms=%d", connectionID, time.Since(totalStarted).Milliseconds()) closeOnError = false var connection net.Conn = uconn if reader.Buffered() > 0 { connection = &bufferedConn{Conn: uconn, reader: reader} } - return &diagnosticConn{Conn: connection, connectionID: connectionID, reporter: d.reporter}, nil + return d.trackConnection(connection, connectionID), nil +} + +// dialProxy resolves the destination proxy at the jump hop. It must never apply +// local-network bypass to this transport connection or fall back to a direct dial. +func (d *httpsConnectDialer) dialProxy(ctx context.Context) (net.Conn, error) { + if d.config.Type != "HTTPS_JUMP" { + return d.protectedDialer().DialContext(ctx, "tcp", d.config.address()) + } + d.cacheMu.Lock() + if d.jump == nil { + c := d.config + c.Type = "HTTPS" + c.Host, c.DialHost, c.Port = c.JumpHost, c.JumpDialHost, c.JumpPort + c.Username, c.Password = c.JumpUsername, c.JumpPassword + if c.SameJumpAuthentication { + c.Username, c.Password = d.config.Username, d.config.Password + } + c.AllowInvalidProxyCertificate = c.JumpAllowInvalidProxyCertificate + c.BypassLocalNetworks = false + d.jump = &httpsConnectDialer{config: c, protector: d.protector, reporter: d.reporter, intermediate: true} + } + jump := d.jump + d.cacheMu.Unlock() + conn, err := jump.connectTarget(ctx, d.config.displayAddress()) + if err != nil { + return nil, fmt.Errorf("HTTPS jump: %w", err) + } + return conn, nil +} + +func (d *httpsConnectDialer) recordOutcome(success bool) { + if !d.intermediate { + recordConnectionOutcome(success) + } +} + +func (d *httpsConnectDialer) recordLatency(elapsed time.Duration) { + if !d.intermediate { + recordProxyLatency(elapsed) + } +} + +func (d *httpsConnectDialer) trackConnection(conn net.Conn, id uint64) net.Conn { + if d.intermediate { + return conn + } + return &diagnosticConn{Conn: conn, connectionID: id, reporter: d.reporter} } type http2ConnectStatusError struct{ status int } @@ -301,7 +353,7 @@ func (d *httpsConnectDialer) openHTTP2Tunnel(ctx context.Context, session *http2 started := time.Now() tunnel, status, err := session.openTunnel(ctx, target, auth) if err != nil { - recordConnectionOutcome(false) + d.recordOutcome(false) if status != 0 { report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=connect_response result=rejected status=%d status_class=%dxx reused_session=%t", connectionID, status, status/100, reused) return nil, &http2ConnectStatusError{status: status} @@ -309,10 +361,10 @@ func (d *httpsConnectDialer) openHTTP2Tunnel(ctx context.Context, session *http2 report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=connect_response result=failed reason=%s reused_session=%t", connectionID, errorClass(err), reused) return nil, fmt.Errorf("HTTP/2 proxy CONNECT: %w", err) } - recordProxyLatency(time.Since(started)) - recordConnectionOutcome(true) + d.recordLatency(time.Since(started)) + d.recordOutcome(true) report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=tunnel result=established stream_multiplexed=true reused_session=%t total_ms=%d", connectionID, reused, time.Since(totalStarted).Milliseconds()) - return &diagnosticConn{Conn: tunnel, connectionID: connectionID, reporter: d.reporter}, nil + return d.trackConnection(tunnel, connectionID), nil } func normalizedALPN(value string) string { diff --git a/native/mobile/https_jump_test.go b/native/mobile/https_jump_test.go new file mode 100644 index 0000000..d8accec --- /dev/null +++ b/native/mobile/https_jump_test.go @@ -0,0 +1,221 @@ +package mobile + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +type jumpTestProtector struct{ calls atomic.Int32 } + +func (p *jumpTestProtector) Protect(int) bool { p.calls.Add(1); return true } + +// Each server accepts exactly one CONNECT authority and credential pair. The +// jump maps a deliberately unresolvable destination hostname to a local server. +func jumpTestProxy(t *testing.T, h2 bool, authority, credentials, forward string, reject bool) *httptest.Server { + t.Helper() + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect || r.Host != authority { + t.Errorf("unexpected request: %s %s", r.Method, r.Host) + w.WriteHeader(http.StatusBadRequest) + return + } + if r.Header.Get("Proxy-Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte(credentials)) { + t.Error("wrong credentials at proxy hop") + w.WriteHeader(http.StatusProxyAuthRequired) + return + } + if reject { + w.WriteHeader(http.StatusForbidden) + return + } + var upstream net.Conn + if forward != "" { + var err error + upstream, err = net.DialTimeout("tcp", forward, 2*time.Second) + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadGateway) + return + } + defer upstream.Close() + } + if r.ProtoMajor == 1 { + conn, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + _, _ = buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n") + _ = buffer.Flush() + if upstream == nil { + _, _ = io.Copy(conn, buffer) + return + } + done := make(chan struct{}) + go func() { _, _ = io.Copy(upstream, buffer); _ = upstream.Close(); close(done) }() + _, _ = io.Copy(conn, upstream) + _ = conn.Close() + <-done + return + } + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + if upstream == nil { + _, _ = io.Copy(jumpFlushWriter{w}, r.Body) + return + } + done := make(chan struct{}) + go func() { _, _ = io.Copy(upstream, r.Body); _ = upstream.Close(); close(done) }() + _, _ = io.Copy(jumpFlushWriter{w}, upstream) + _ = r.Body.Close() + <-done + })) + server.EnableHTTP2 = h2 + server.StartTLS() + t.Cleanup(server.Close) + return server +} + +type jumpFlushWriter struct{ http.ResponseWriter } + +func (w jumpFlushWriter) Write(p []byte) (int, error) { + n, err := w.ResponseWriter.Write(p) + w.ResponseWriter.(http.Flusher).Flush() + return n, err +} + +func TestHTTPSJumpTunnel(t *testing.T) { + for _, jumpH2 := range []bool{false, true} { + for _, exitH2 := range []bool{false, true} { + t.Run(fmt.Sprintf("jump_h2=%t/exit_h2=%t", jumpH2, exitH2), func(t *testing.T) { + exit := jumpTestProxy(t, exitH2, "site.invalid:443", "exit:exit-secret", "", false) + jump := jumpTestProxy(t, jumpH2, "exit.invalid:443", "jump:jump-secret", exit.Listener.Addr().String(), false) + host, port, _ := net.SplitHostPort(jump.Listener.Addr().String()) + portNumber, _ := strconv.Atoi(port) + protector := &jumpTestProtector{} + d := &httpsConnectDialer{protector: protector, config: config{ + Type: "HTTPS_JUMP", Host: "exit.invalid", Port: 443, + // A direct dial to this address would fail. Only the jump can resolve exit.invalid. + DialHost: "192.0.2.1", Username: "exit", Password: "exit-secret", + JumpHost: "jump.invalid", JumpDialHost: host, JumpPort: portNumber, + JumpUsername: "jump", JumpPassword: "jump-secret", + AllowInvalidProxyCertificate: true, JumpAllowInvalidProxyCertificate: true, + Profile: "CHROME_ANDROID", BypassLocalNetworks: true, + }} + defer d.Close() + before := snapshotStats() + for i := 0; i < 2; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + conn, err := d.connectTarget(ctx, "site.invalid:443") + cancel() + if err != nil { + t.Fatal(err) + } + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + payload := "hello through two HTTPS proxies\n" + if _, err := io.WriteString(conn, payload); err != nil { + t.Fatal(err) + } + got, err := bufio.NewReader(conn).ReadString('\n') + _ = conn.Close() + if err != nil || got != payload { + t.Fatalf("echo = %q, %v", got, err) + } + } + after := snapshotStats() + wantBytes := uint64(2 * len("hello through two HTTPS proxies\n")) + if after.DownloadBytes-before.DownloadBytes != wantBytes || after.UploadBytes-before.UploadBytes != wantBytes { + t.Fatal("intermediate TLS tunnel must not count application traffic twice") + } + if after.TotalOutcomes-before.TotalOutcomes != 2 { + t.Fatal("only end-to-end connection outcomes should be counted") + } + if got := protector.calls.Load(); got < 1 || got > 2 { + t.Fatalf("protected sockets = %d", got) + } + if d.jump == nil || d.jump.config.BypassLocalNetworks { + t.Fatal("jump transport must not bypass CONNECT") + } + }) + } + } +} + +func TestHTTPSJumpRejectsFailedHop(t *testing.T) { + for _, failure := range []string{"jump_certificate", "exit_certificate", "jump_connect", "exit_connect"} { + t.Run(failure, func(t *testing.T) { + exit := jumpTestProxy(t, false, "site.invalid:443", "user:secret", "", failure == "exit_connect") + jump := jumpTestProxy(t, false, "exit.invalid:443", "user:secret", exit.Listener.Addr().String(), failure == "jump_connect") + host, port, _ := net.SplitHostPort(jump.Listener.Addr().String()) + portNumber, _ := strconv.Atoi(port) + protector := &jumpTestProtector{} + d := &httpsConnectDialer{protector: protector, config: config{ + Type: "HTTPS_JUMP", Host: "exit.invalid", Port: 443, + Username: "user", Password: "secret", SameJumpAuthentication: true, + JumpHost: "jump.invalid", JumpDialHost: host, JumpPort: portNumber, + AllowInvalidProxyCertificate: failure != "exit_certificate", + JumpAllowInvalidProxyCertificate: failure != "jump_certificate", Profile: "CHROME_ANDROID", + }} + defer d.Close() + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + conn, err := d.connectTarget(ctx, "site.invalid:443") + if conn != nil { + _ = conn.Close() + t.Fatal("failed hop yielded a connection") + } + if err == nil { + t.Fatal("expected failed hop") + } + if strings.HasPrefix(failure, "jump") && !strings.Contains(err.Error(), "HTTPS jump") { + t.Fatalf("missing hop context: %v", err) + } + if protector.calls.Load() != 1 { + t.Fatal("failure must not trigger a direct connection") + } + }) + } +} + +func TestHTTPSJumpConfigValidation(t *testing.T) { + valid := config{Type: "HTTPS_JUMP", Host: "exit.invalid", Port: 443, + Username: "u", Password: "p", JumpHost: "jump.invalid", JumpDialHost: "127.0.0.1", JumpPort: 443, + SameJumpAuthentication: true, Profile: "CHROME_ANDROID", DoHURL: "https://dns.google/dns-query"} + raw, _ := json.Marshal(valid) + parsed, err := parseConfig(string(raw)) + if err != nil { + t.Fatal(err) + } + if parsed.JumpUsername != "u" || parsed.JumpPassword != "p" { + t.Fatal("shared credentials not applied") + } + for _, change := range []func(*config){ + func(c *config) { c.JumpHost = "" }, + func(c *config) { c.JumpPort = 0 }, + func(c *config) { c.JumpDialHost = "" }, + func(c *config) { c.SameJumpAuthentication = false }, + func(c *config) { c.Password = "" }, + func(c *config) { c.Profile = "invalid" }, + } { + c := valid + change(&c) + raw, _ := json.Marshal(c) + if _, err := parseConfig(string(raw)); err == nil { + t.Fatal("invalid jump configuration accepted") + } + } +} diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go index 53b33c7..959cf82 100644 --- a/native/mobile/mobile.go +++ b/native/mobile/mobile.go @@ -69,7 +69,7 @@ func Start(tunFD int, rawConfig string, protector Protector, reporter Reporter) } t := tunnel.T() var proxyCloser io.Closer - if c.Type == "HTTPS" { + if c.isHTTPS() { httpsProxy := &httpsConnectDialer{config: c, protector: protector, reporter: reporter} t.SetProxy(httpsProxy) proxyCloser = httpsProxy diff --git a/native/mobile/test_connection.go b/native/mobile/test_connection.go index 67f8f3a..909d3d5 100644 --- a/native/mobile/test_connection.go +++ b/native/mobile/test_connection.go @@ -44,9 +44,10 @@ func TestConnection(rawConfig string, protector Protector, reporter Reporter) (s } var connect func(context.Context, string) (net.Conn, error) var testReporter Reporter - if c.Type == "HTTPS" { + if c.isHTTPS() { dialer := &httpsConnectDialer{config: c, protector: protector, reporter: reporter} connect, testReporter = dialer.connectTarget, dialer.reporter + defer dialer.Close() } else { dialer := &sshDialer{config: c, protector: protector, reporter: reporter} connect, testReporter = dialer.connectTarget, dialer.reporter From 624c9fe4ae827df49829d139d8aac76634cf4a0e Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 7 Sep 2026 15:51:40 +0300 Subject: [PATCH 2/2] Refine profile selector and show connection session timing --- .../java/net/megaproxy487/MainActivity.kt | 133 ++++++++++++++++-- .../net/megaproxy487/vpn/ConnectionTiming.kt | 29 ++++ .../net/megaproxy487/vpn/VpnRuntimeState.kt | 9 ++ app/src/main/res/values-ru/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../megaproxy487/vpn/ConnectionTimingTest.kt | 39 +++++ 6 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/net/megaproxy487/vpn/ConnectionTiming.kt create mode 100644 app/src/test/java/net/megaproxy487/vpn/ConnectionTimingTest.kt diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 7019e50..746aa05 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -7,6 +7,12 @@ import android.content.pm.PackageManager import android.net.VpnService import android.os.Build import android.os.Bundle +import android.os.SystemClock +import java.text.DateFormat +import java.util.Date +import androidx.compose.ui.platform.LocalConfiguration +import net.megaproxy487.vpn.ConnectionSession +import net.megaproxy487.vpn.connectionDuration import androidx.activity.ComponentActivity import androidx.activity.enableEdgeToEdge import androidx.activity.compose.rememberLauncherForActivityResult @@ -58,6 +64,14 @@ import androidx.compose.runtime.setValue import androidx.compose.foundation.clickable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.stringResource @@ -70,6 +84,7 @@ import androidx.compose.ui.semantics.stateDescription import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -148,8 +163,9 @@ class MainActivity : LocalizedActivity() { } @Composable -private fun ProfileTypeBadge(type: ProxyType, foreground: Color) { +private fun ProfileTypeBadge(type: ProxyType, foreground: Color, modifier: Modifier = Modifier) { Surface( + modifier = modifier, color = foreground.copy(alpha = 0.14f), contentColor = foreground, shape = RoundedCornerShape(50), @@ -162,8 +178,53 @@ private fun ProfileTypeBadge(type: ProxyType, foreground: Color) { ProxyType.SSH -> "SSH" ProxyType.SSH_JUMP -> "SSH + Jump" }, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.sp), + maxLines = 1, + softWrap = false, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), + ) + } +} + +/** Keep the type readable while a long name fades beneath its trailing badge. */ +@Composable +private fun ProfileSelectorLabel( + name: String, + type: ProxyType, + foreground: Color, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodyLarge, +) { + var badgeWidth by remember { mutableStateOf(0) } + Box(modifier, contentAlignment = Alignment.CenterStart) { + Text( + name, + color = foreground, + style = style, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + modifier = Modifier.fillMaxWidth() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + // Mask only the name layer: the badge and its border stay crisp. + val end = (size.width - badgeWidth - 6.dp.toPx()).coerceAtLeast(0f) + val start = (end - 24.dp.toPx()).coerceAtLeast(0f) + drawRect( + brush = Brush.horizontalGradient( + colors = listOf(Color.Black, Color.Transparent), + startX = start, + endX = end.coerceAtLeast(start + 1f), + ), + blendMode = BlendMode.DstIn, + ) + }, + ) + ProfileTypeBadge( + type, + foreground, + Modifier.align(Alignment.CenterEnd).onSizeChanged { badgeWidth = it.width }, ) } } @@ -421,21 +482,32 @@ internal fun MainScreen( Text(activeProfile.flagEmoji, modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)) } } - Text(activeProfile.displayName, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) - ProfileTypeBadge(activeProfile.config.type, onProfileColor) + ProfileSelectorLabel( + activeProfile.displayName, + activeProfile.config.type, + onProfileColor, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + ) Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.select_profile), tint = onProfileColor) } if (actualProfile != null && actualProfile.id != activeProfile.id && connection != VpnConnectionState.DISCONNECTED) { Text(stringResource(R.string.connected_through, actualProfile.displayNameWithFlag), style = MaterialTheme.typography.bodySmall) } - DropdownMenu(profileMenuExpanded, { profileMenuExpanded = false }) { + DropdownMenu( + profileMenuExpanded, + { profileMenuExpanded = false }, + modifier = Modifier.widthIn(min = 280.dp), + ) { profiles.forEach { profile -> DropdownMenuItem( text = { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(profile.displayNameWithFlag, modifier = Modifier.weight(1f)) - ProfileTypeBadge(profile.config.type, MaterialTheme.colorScheme.onSurface) - } + ProfileSelectorLabel( + profile.displayNameWithFlag, + profile.config.type, + MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) }, onClick = { val useAsAlwaysOn = isAlwaysOnVpnActive(activity) @@ -715,6 +787,47 @@ private fun ConnectionStatsCard(stats: DisplayedConnectionStats) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 12.dp), ) + val session by VpnRuntimeState.session + session?.let { ConnectionTimingDetails(it) } + } +} + +@Composable +private fun ConnectionTimingDetails(session: ConnectionSession) { + val lifecycleOwner = LocalLifecycleOwner.current + var duration by remember(session) { + mutableStateOf(connectionDuration(session.elapsedMillis(SystemClock.elapsedRealtime()))) + } + LaunchedEffect(session, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + duration = connectionDuration(session.elapsedMillis(SystemClock.elapsedRealtime())) + delay(duration.nextUpdateDelayMillis) + } + } + } + val locale = LocalConfiguration.current.locales[0] + val startedAt = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM, locale) + .format(Date(session.startedAtMillis)) + val seconds = duration.seconds + val elapsed = when { + seconds < 60 -> stringResource(R.string.connection_duration_seconds, seconds) + seconds < 600 -> stringResource(R.string.connection_duration_minutes_seconds, seconds / 60, seconds % 60) + seconds < 3_600 -> stringResource(R.string.connection_duration_minutes, seconds / 60) + seconds < 86_400 -> stringResource(R.string.connection_duration_hours_minutes, seconds / 3_600, seconds / 60 % 60) + else -> stringResource(R.string.connection_duration_days_hours, seconds / 86_400, seconds / 3_600 % 24) + } + Column(Modifier.padding(start = 14.dp, end = 14.dp, bottom = 12.dp)) { + Text( + stringResource(R.string.connection_started_at, startedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + stringResource(R.string.connection_duration, elapsed), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } diff --git a/app/src/main/java/net/megaproxy487/vpn/ConnectionTiming.kt b/app/src/main/java/net/megaproxy487/vpn/ConnectionTiming.kt new file mode 100644 index 0000000..4bd2f3c --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/ConnectionTiming.kt @@ -0,0 +1,29 @@ +package net.megaproxy487.vpn + +/** Wall time labels the session; monotonic time measures it across clock changes and sleep. */ +data class ConnectionSession(val startedAtMillis: Long, val startedAtElapsedMillis: Long) { + fun elapsedMillis(nowElapsedMillis: Long): Long = (nowElapsedMillis - startedAtElapsedMillis).coerceAtLeast(0) +} + +fun connectionSessionForState( + current: ConnectionSession?, + connected: Boolean, + wallTimeMillis: Long, + elapsedRealtimeMillis: Long, +): ConnectionSession? = if (connected) current ?: ConnectionSession(wallTimeMillis, elapsedRealtimeMillis) else null + +data class ConnectionDuration(val seconds: Long, val nextUpdateDelayMillis: Long) + +fun connectionDuration(elapsedMillis: Long): ConnectionDuration { + val elapsed = elapsedMillis.coerceAtLeast(0) + val stepMillis = when { + elapsed < 60_000 -> 10_000L + elapsed < 600_000 -> 30_000L + elapsed < 86_400_000 -> 60_000L + else -> 3_600_000L + } + return ConnectionDuration( + seconds = elapsed / stepMillis * (stepMillis / 1_000), + nextUpdateDelayMillis = stepMillis - elapsed % stepMillis, + ) +} diff --git a/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt b/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt index 8c29564..7e838f1 100644 --- a/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt +++ b/app/src/main/java/net/megaproxy487/vpn/VpnRuntimeState.kt @@ -1,6 +1,7 @@ package net.megaproxy487.vpn import android.os.Handler +import android.os.SystemClock import android.os.Looper import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf @@ -35,8 +36,16 @@ object VpnRuntimeState { private val mutableTransportProtocol = mutableStateOf(VpnTransportProtocol.UNKNOWN) val transportProtocol: State = mutableTransportProtocol + private val mutableSession = mutableStateOf(null) + val session: State = mutableSession + fun update(value: VpnConnectionState) { + val wallTime = System.currentTimeMillis() + val elapsedTime = SystemClock.elapsedRealtime() val update = { + mutableSession.value = connectionSessionForState( + mutableSession.value, value == VpnConnectionState.CONNECTED, wallTime, elapsedTime, + ) mutableConnection.value = value if (value != VpnConnectionState.CONNECTED) mutableTransportProtocol.value = VpnTransportProtocol.UNKNOWN } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1ac730..a61711e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -277,4 +277,11 @@ Пользовательский URL DoH должен начинаться с https:// Введите имя пользователя Basic Auth промежуточного прокси Введите пароль Basic Auth промежуточного прокси + Подключено: %1$s + Длительность: %1$s + %1$d с + %1$d мин %2$d с + %1$d мин + %1$d ч %2$d мин + %1$d д %2$d ч diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fffce0d..debb1e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,4 +272,11 @@ The custom DoH URL must start with https:// Enter the jump Basic Auth username Enter the jump Basic Auth password + Connected: %1$s + Duration: %1$s + %1$d s + %1$d min %2$d s + %1$d min + %1$d h %2$d min + %1$d d %2$d h diff --git a/app/src/test/java/net/megaproxy487/vpn/ConnectionTimingTest.kt b/app/src/test/java/net/megaproxy487/vpn/ConnectionTimingTest.kt new file mode 100644 index 0000000..7f0d999 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/vpn/ConnectionTimingTest.kt @@ -0,0 +1,39 @@ +package net.megaproxy487.vpn + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConnectionTimingTest { + @Test fun durationPrecisionChangesAtTheRequestedBoundaries() { + val cases = listOf( + Triple(-1L, 0L, 10_000L), + Triple(9_999L, 0L, 1L), + Triple(10_000L, 10L, 10_000L), + Triple(59_999L, 50L, 1L), + Triple(60_000L, 60L, 30_000L), + Triple(89_999L, 60L, 1L), + Triple(90_000L, 90L, 30_000L), + Triple(599_999L, 570L, 1L), + Triple(600_000L, 600L, 60_000L), + Triple(659_999L, 600L, 1L), + Triple(86_399_999L, 86_340L, 1L), + Triple(86_400_000L, 86_400L, 3_600_000L), + Triple(89_999_999L, 86_400L, 1L), + Triple(90_000_000L, 90_000L, 3_600_000L), + ) + cases.forEach { (elapsed, seconds, next) -> + assertEquals("elapsed=$elapsed", ConnectionDuration(seconds, next), connectionDuration(elapsed)) + } + } + + @Test fun sessionSurvivesRepeatedConnectedUpdatesButRestartsAfterReconnect() { + val session = connectionSessionForState(null, true, 1_000_000, 10_000)!! + assertEquals(session, connectionSessionForState(session, true, 2_000_000, 20_000)) + assertEquals(60_000L, session.elapsedMillis(70_000)) + assertEquals(1_000_000L, session.startedAtMillis) + val disconnected = connectionSessionForState(session, false, 2_000_000, 80_000) + assertNull(disconnected) + assertEquals(ConnectionSession(3_000_000, 90_000), connectionSessionForState(disconnected, true, 3_000_000, 90_000)) + } +}