diff --git a/AGENTS.md b/AGENTS.md index a9fafbd1..68d45d8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ branch names, credentials, signing material, or other secrets. JVM checks validate real exports against both schemas. Keep permissive legacy imports; omitted transport types default to HTTPS, but explicit unsupported transports fail instead of changing protocol. Report - ignored browser settings and undocumented keys once each in the existing import result, without + ignored browser settings (except supported HTTPS `browser.knockHost`) and undocumented keys once each in the existing import result, without field values. Unsupported fields are discarded and do not round-trip through Android. ## Releases and distribution @@ -153,6 +153,13 @@ branch names, credentials, signing material, or other secrets. and `docs/ru/release-testing.md`; select manual checks by changed risk rather than repeating the entire feature matrix on every device. +- Regular HTTPS/TCP diagnostics may use an optional per-profile authentication probe hostname, + imported/exported as `proxy.knockHost`, with legacy `browser.knockHost` fallback. Only an explicit 407 proves authentication rejection; + 200, timeouts and unexpected statuses do not prove absence/presence of a decoy. Keep probes + bounded, always proxied despite LAN bypass, outside packet handling and normal VPN startup. + Diagnostic-only changes do not require reconnects; live tests retain runtime credentials. + Log only outcomes, status, timing and failure classes, never addresses or credentials. + - DNS security exceptions require local confirmation, persist per profile and never export. Plain TCP DNS uses the proxy even for LAN resolvers; UDP uses actual MASQUE/SOCKS5 capability, otherwise protected direct UDP for HTTPS/TCP or SSH, with no direct fallback after tunnel failure. DoH certificate exceptions never change proxy trust. - MASQUE uses protected HTTP/3 QUIC sockets with Basic Auth; DNS defaults to DoH; optional plain DNS routing is documented in `docs/en/dns.md`. GOST uses the `http3` listener, not `h3`. Pinned uQUIC sources and compatibility patches live in `native/third_party/uquic`; keep packet-size and GOST IPv6 UDP limitations documented. diff --git a/PRIVACY.md b/PRIVACY.md index 24afd025..8cc9891f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -40,7 +40,7 @@ DNS queries to interception. These exceptions require local confirmation and are Applications can also use their own encrypted DNS connections; these follow normal routing. When you run a connection test, MegaProxy contacts a test website and external IP/country lookup -services through the proxy; MASQUE and SOCKS5 tests also contact external HTTP/3 diagnostic services. +services through the proxy; an optional profile-provided authentication check host is also contacted through the HTTPS proxy. MASQUE and SOCKS5 tests also contact external HTTP/3 diagnostic services. They receive the exit IP and test requests, including TLS/QUIC connection parameters, to check connectivity and identify the proxy's apparent country, not your GPS location. The current services are listed in [Network privacy details](README.md#privacy-and-security). These providers handle requests under diff --git a/README.md b/README.md index 85207a89..f88d86f5 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ configured subscriptions and enabled app-update checks can also run in the backg | Proxy bootstrap DNS | Cloudflare, Yandex, Google or Quad9 DoH may be contacted directly before VPN exists | | Application DNS | DoH through the proxy by default; optional TCP DNS through the proxy or UDP DNS (direct for HTTPS/TCP and SSH). Intercepted UDP/TCP port 53 only; app-specific encrypted DNS follows normal routing. [Routing and security](docs/en/dns.md) | | Connection test | `example.com`; exit IP/country from `speed.cloudflare.com/cdn-cgi/trace`, then IP fallbacks `ifconfig.me`, `api.ipify.org`, `icanhazip.com` and country fallbacks `ifconfig.co`, `ipapi.co`, `api.country.is` | +| Optional proxy authentication check | Profile-provided `proxy.knockHost`, CONNECT to port 443 through the HTTPS proxy during diagnostics; [masked authentication](docs/en/profiles-and-routing.md#masked-authentication-errors) | | UDP/HTTP3 test | `www.cloudflare.com/cdn-cgi/trace` and `quic.browserleaks.com/`, independently through MASQUE/SOCKS5 UDP with verified certificates and no TCP fallback | | VPN visibility | Route IP lookup as above, then `https://proxycheck.io/v3/`; successful reputation results cached per IP for 24 hours, quota exhaustion shown separately | | Speed | `https://h3.speed.cloudflare.com/cdn-cgi/trace`, `/__down`, `/__up`; underlying/VPN networks separately, 10/25/50 decimal MB per direction (up to four times the selected amount plus overhead) | diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index 5b063147..ce5da738 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -173,13 +173,13 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: fun acceptText(value: String, maxLength: Int, update: (String) -> Unit) { if (value.length <= maxLength) update(value) } - fun updateConfig(updated: ProxyConfig) { + fun updateConfig(updated: ProxyConfig, affectsConnection: Boolean = true) { if (updated == config) return config = updated profile = profile.copy(config = updated) - saveProfile(affectsConnection = true) + saveProfile(affectsConnection = affectsConnection) error = globalSettings.applyTo(updated).connectionValidationError()?.let { activity.uiText(it) } - if (ProxyVpnService.isRunning && !connectionChangeDeferred && profile.id == editedConnectionProfileId) { + if (affectsConnection && ProxyVpnService.isRunning && !connectionChangeDeferred && profile.id == editedConnectionProfileId) { if (alwaysOnActive) { connectionChangeDeferred = true showAlwaysOnNotice = true @@ -356,6 +356,17 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: description = stringResource(R.string.prefer_http3_description), onCheckedChange = { updateConfig(config.copy(preferHttp3 = it)) }, ) } + if (config.type == ProxyType.HTTPS) item { + OutlinedTextField( + config.authProbeHost, + { value -> acceptText(value, 253) { updateConfig(config.copy(authProbeHost = it), affectsConnection = false) } }, + label = { FieldLabel(stringResource(R.string.auth_probe_host)) }, + isError = fieldError(R.string.validation_auth_probe_host) != null, + supportingText = { Text(fieldError(R.string.validation_auth_probe_host) + ?: stringResource(R.string.auth_probe_host_help)) }, + singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard(), + ) + } if (config.type == ProxyType.MASQUE) { item { Text(stringResource(R.string.masque_fingerprint_description), style = MaterialTheme.typography.bodySmall) } if (globalSettings.tlsProfile == TlsProfile.CUSTOM) item { diff --git a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt index 21b4e0f0..dd9f1f59 100644 --- a/app/src/main/java/net/megaproxy487/ProfilesScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -130,6 +130,7 @@ private fun profileOptionGroups(current: ProxyProfile, imported: ProxyProfile): if (current.config.type != imported.config.type || current.config.port != imported.config.port || current.config.jumpPort != imported.config.jumpPort || current.config.preferHttp3 != imported.config.preferHttp3 || + current.config.authProbeHost != imported.config.authProbeHost || current.config.sameJumpAuthentication != imported.config.sameJumpAuthentication ) add(R.string.import_option_connection) if (current.config.allowInvalidProxyCertificate != imported.config.allowInvalidProxyCertificate || @@ -181,6 +182,7 @@ internal fun applySelectedProfileOptions( type = imported.config.type, port = imported.config.port, preferHttp3 = imported.config.preferHttp3, + authProbeHost = imported.config.authProbeHost, jumpPort = imported.config.jumpPort, sameJumpAuthentication = imported.config.sameJumpAuthentication, )) diff --git a/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt b/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt index ee394592..5c8f0d71 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt @@ -47,6 +47,7 @@ internal fun ConfigEdits.profile(before: ProxyProfile, after: Prox it.copy(config = it.config.copy(password = after.config.password, unreadableSecrets = it.config.unreadableSecrets - "password")) } record("allowInvalidProxyCertificate", before.config.allowInvalidProxyCertificate, after.config.allowInvalidProxyCertificate) { it.copy(config = it.config.copy(allowInvalidProxyCertificate = after.config.allowInvalidProxyCertificate)) } + record("authProbeHost", before.config.authProbeHost, after.config.authProbeHost) { it.copy(config = it.config.copy(authProbeHost = after.config.authProbeHost)) } record("preferHttp3", before.config.preferHttp3, after.config.preferHttp3) { it.copy(config = it.config.copy(preferHttp3 = after.config.preferHttp3)) } record("profile", before.config.profile, after.config.profile) { it.copy(config = it.config.copy(profile = after.config.profile)) } record("customJa3", before.config.customJa3, after.config.customJa3) { it.copy(config = it.config.copy(customJa3 = after.config.customJa3)) } diff --git a/app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt b/app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt index ceb587e1..9a47cf93 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigImportNotice.kt @@ -41,7 +41,13 @@ internal object ConfigImportNotices { walk(root, schema) val profiles = root.optJSONArray("profiles") val browser = root.has("browser") || profiles?.let { array -> - (0 until array.length()).any { array.optJSONObject(it)?.has("browser") == true } + (0 until array.length()).any { index -> + val profile = array.optJSONObject(index) + val browser = profile?.optJSONObject("browser") + val supported = profile?.optJSONObject("proxy")?.optString("type", "HTTPS") == "HTTPS" + if (browser != null) browser.keys().asSequence().any { it != "knockHost" || !supported } + else profile?.has("browser") == true + } } == true return ConfigImportNotice(browserFields = browser, unknownFields = unknown) } diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index fca0b989..18289deb 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -166,7 +166,7 @@ class ConfigStore(context: Context) { /** Compare the config actually started, before bootstrap IPs were filled in. */ fun reconcileStartedConfiguration(profileId: String, config: ProxyConfig, appliedToken: String?): String? = synchronized(storageLock) { - if (connectionProfileId() != profileId || connectionConfig() != config) { + if (connectionProfileId() != profileId || connectionConfig().copy(authProbeHost = "") != config.copy(authProbeHost = "")) { if (!hasPendingReconnect()) markPendingReconnect() } else clearPendingReconnect(appliedToken) pendingReconnectToken() @@ -304,7 +304,8 @@ class ConfigStore(context: Context) { val merged = configuration.profiles.map { source -> val current = existingById[source.id] val selected = if (current == null) source else resolveProfile(current, source) - val resolved = retainProfileSecrets(selected, current, configuration.secretPresence[source.id] ?: ProfileSecretPresence()) + val resolved = retainProfileSecrets(selected, current, configuration.secretPresence[source.id] ?: ProfileSecretPresence(), + knockHostPresent = configuration.knockHostPresent?.contains(source.id) != false) when { current == null -> added += resolved current == resolved -> unchanged += resolved @@ -393,7 +394,8 @@ class ConfigStore(context: Context) { } val byId = existing.associateBy(ProxyProfile::id) val incoming = configuration.profiles.map { source -> retainProfileSecrets(source, byId[source.id], - configuration.secretPresence[source.id] ?: ProfileSecretPresence()) } + configuration.secretPresence[source.id] ?: ProfileSecretPresence(), + knockHostPresent = configuration.knockHostPresent?.contains(source.id) != false) } val merged = local + incoming val ids = merged.map(ProxyProfile::id).toSet() requireUi(ids.size == merged.size) { UiException(R.string.error_config_duplicate_ids) } @@ -417,7 +419,7 @@ class ConfigStore(context: Context) { if ((connectionProfileId() !in ids || alwaysOnProfileId() !in ids) && isFailoverActive()) { editor.putBoolean(FAILOVER_ACTIVE, false).remove(FAILOVER_NOTICE) } - if (connectionRunning && (oldConnection != newConnection || connectionProfileId() != nextConnection)) { + if (connectionRunning && (oldConnection.copy(authProbeHost = "") != newConnection.copy(authProbeHost = "") || connectionProfileId() != nextConnection)) { editor.putString(PENDING_RECONNECT, UUID.randomUUID().toString()) } putSubscription(editor, state.copy(settings = nextSettings, ownedIds = incoming.map(ProxyProfile::id).toSet(), @@ -686,6 +688,7 @@ class ConfigStore(context: Context) { put("sameJumpAuthentication", config.sameJumpAuthentication) put("allowInvalidProxyCertificate", config.allowInvalidProxyCertificate) put("preferHttp3", config.preferHttp3) + put("authProbeHost", config.authProbeHost.trim()) put("fingerprint", config.profile.name) put("customJa3", config.customJa3.trim()) put("dnsProvider", config.dnsProvider.name) @@ -738,6 +741,7 @@ class ConfigStore(context: Context) { sameJumpAuthentication = item.optBoolean("sameJumpAuthentication", true), allowInvalidProxyCertificate = item.optBoolean("allowInvalidProxyCertificate", false), preferHttp3 = item.optBoolean("preferHttp3", false), + authProbeHost = item.optString("authProbeHost"), profile = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT), customJa3 = item.optString("customJa3"), dnsProvider = enumValue(item.optString("dnsProvider"), DnsProvider.CLOUDFLARE), diff --git a/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt b/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt index 46d15079..d8db6db9 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt @@ -126,6 +126,7 @@ internal fun retainProfileSecrets( source: net.megaproxy487.model.ProxyProfile, current: net.megaproxy487.model.ProxyProfile?, presence: ProfileSecretPresence, + knockHostPresent: Boolean = true, ): net.megaproxy487.model.ProxyProfile { if (current == null) return source val sameDns = source.config.dnsProvider == current.config.dnsProvider && @@ -134,6 +135,7 @@ internal fun retainProfileSecrets( val sameAccount = sameProxyAccount(source.config, current.config, jump = false) val sameJumpAccount = sameProxyAccount(source.config, current.config, jump = true) return source.copy(config = source.config.copy( + authProbeHost = if (knockHostPresent || !sameAccount) source.config.authProbeHost else current.config.authProbeHost, allowInsecureDns = sameDns && current.config.allowInsecureDns, allowInvalidDohCertificate = sameDns && current.config.allowInvalidDohCertificate, password = if (presence.password) source.config.password else current.config.password.takeIf { sameAccount }.orEmpty(), diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index b7f5de67..639453b0 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -5,6 +5,7 @@ import net.megaproxy487.UiException import net.megaproxy487.requireUi import net.megaproxy487.model.DnsProvider +import net.megaproxy487.model.validAuthProbeHost import net.megaproxy487.model.ProxyConfig import net.megaproxy487.model.ProxyProfile import net.megaproxy487.model.ProxyType @@ -36,6 +37,7 @@ data class PortableConfiguration( val subscription: ConfigSubscription? = null, val globalSettingFields: Set? = null, val diagnosticLogLimitPresent: Boolean = true, + val knockHostPresent: Set? = null, ) { internal fun mergeGlobalSettings(current: GlobalConnectionSettings): GlobalConnectionSettings { val incoming = globalConnectionSettings ?: return current @@ -180,6 +182,11 @@ object ConfigTransfer { } } else null, skippedProfiles = decoded.count(Result::isFailure), + knockHostPresent = (0 until array.length()).mapNotNull { index -> + val item = array.optJSONObject(index) ?: return@mapNotNull null + if (item.optJSONObject("proxy")?.has("knockHost") == true || item.optJSONObject("browser")?.has("knockHost") == true) + item.optString("id").ifBlank { "import-$index" } else null + }.toSet(), secretPresence = (0 until array.length()).mapNotNull { index -> val item = array.optJSONObject(index) ?: return@mapNotNull null val id = item.optString("id").ifBlank { "import-$index" } @@ -245,6 +252,7 @@ object ConfigTransfer { if (includePrivateKeys) put("privateKey", profile.config.privateKey) put("allowInvalidProxyCertificate", profile.config.allowInvalidProxyCertificate) put("preferHttp3", profile.config.preferHttp3) + if (profile.config.type == ProxyType.HTTPS) put("knockHost", profile.config.authProbeHost.trim()) put("sshProfile", profile.config.sshProfile.name) put("trustedHostKey", profile.config.trustedHostKey) put("acceptAnyHostKey", profile.config.acceptAnyHostKey) @@ -286,6 +294,14 @@ object ConfigTransfer { val host = proxy.limitedString("host", 253).trim() requireUi(host.isNotEmpty() && !host.contains(Regex("[/:\\s]"))) val type = enumValue(proxy.optString("type"), ProxyType.HTTPS) + val browser = item.optJSONObject("browser") + val probeOptions = if (proxy.has("knockHost")) proxy else browser + val authProbeHost = if (type == ProxyType.HTTPS && probeOptions?.has("knockHost") == true) { + requireUi(probeOptions.opt("knockHost") is String) { UiException(R.string.validation_auth_probe_host) } + probeOptions.limitedString("knockHost", 253).trim().also { + requireUi(validAuthProbeHost(it)) { UiException(R.string.validation_auth_probe_host) } + } + } else "" val jump = proxy.optJSONObject("jump") val packages = routing.optJSONArray("selectedPackages")?.let { array -> requireUi(array.length() <= MAX_IMPORTED_PACKAGES) { UiException(R.string.error_profile_packages) } @@ -321,6 +337,7 @@ object ConfigTransfer { jumpAcceptAnyHostKey = jump?.optBoolean("acceptAnyHostKey", false) ?: false, allowInvalidProxyCertificate = proxy.optBoolean("allowInvalidProxyCertificate", false), preferHttp3 = proxy.optBoolean("preferHttp3", false), + authProbeHost = authProbeHost, profile = enumValue(tls.optString("fingerprint"), TlsProfile.DEFAULT), customJa3 = tls.limitedString("customJa3", MAX_CUSTOM_JA3_LENGTH), dnsProvider = enumValue(dns.optString("provider"), DnsProvider.CLOUDFLARE), diff --git a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt index 45188387..aaddbffa 100644 --- a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt +++ b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt @@ -20,6 +20,7 @@ data class ProxyConfig( val password: String = "", val allowInvalidProxyCertificate: Boolean = false, val preferHttp3: Boolean = false, + val authProbeHost: String = "", val profile: TlsProfile = TlsProfile.DEFAULT, val customJa3: String = "", val dnsProvider: DnsProvider = DnsProvider.CLOUDFLARE, @@ -73,6 +74,7 @@ data class ProxyConfig( host.isBlank() -> if (!type.isSsh) 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 == ProxyType.HTTPS && !validAuthProbeHost(authProbeHost.trim()) -> R.string.validation_auth_probe_host type.isHttpProxy && username.isBlank() -> R.string.validation_basic_username type.isHttpProxy && ':' in username -> R.string.validation_basic_username_colon type.isHttpProxy && password.isBlank() -> R.string.validation_basic_password @@ -305,3 +307,13 @@ internal fun validPlainDnsUrl(value: String): Boolean = runCatching { (uri.port == -1 || uri.port in 1..65535) && uri.rawPath.isNullOrEmpty() && uri.rawUserInfo == null && uri.rawQuery == null && uri.rawFragment == null }.getOrDefault(false) + +/** Hostname or IP literal: no URL, credentials, path or port. */ +fun validAuthProbeHost(host: String): Boolean = host.isEmpty() || + (host.length <= 253 && host.contains(':') && !host.contains('%') && runCatching { + val literal = host.removeSurrounding("[", "]") + java.net.URI("https://[$literal]/").host != null + }.getOrDefault(false)) || + (host.length <= 253 && host.split('.').all { label -> + label.length in 1..63 && label.matches(Regex("[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?")) + }) diff --git a/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt b/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt index 149985b1..08b4e025 100644 --- a/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt +++ b/app/src/main/java/net/megaproxy487/vpn/BlockingDetection.kt @@ -39,7 +39,7 @@ internal fun nativeFailureReason(message: String): String { "certificate" in value || "x509" in value -> "certificate" listOf("authentication", "authenticate", "credentials", "status 407").any(value::contains) -> "authentication" "protect rejected" in value -> "vpn_protect" - "timeout" in value || "timed out" in value -> "timeout" + "timeout" in value || "timed out" in value || "deadline exceeded" in value -> "timeout" "reset" in value || "broken pipe" in value -> "reset" "refused" in value -> "refused" "unreachable" in value -> "unreachable" diff --git a/app/src/main/java/net/megaproxy487/vpn/ConnectionTestTarget.kt b/app/src/main/java/net/megaproxy487/vpn/ConnectionTestTarget.kt index 63836f21..608a1616 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ConnectionTestTarget.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ConnectionTestTarget.kt @@ -11,14 +11,15 @@ internal fun connectionTestTarget( selected: () -> ConnectionTestTarget, ): ConnectionTestTarget = runtime ?: selected() -/** A fresh diagnostic session must observe a just-approved key without applying draft settings. */ -internal fun ConnectionTestTarget.withStoredTrust(profile: ProxyProfile?): ConnectionTestTarget { +/** Refresh diagnostic metadata and approved keys without applying draft connection settings. */ +internal fun ConnectionTestTarget.withStoredDiagnosticSettings(profile: ProxyProfile?): ConnectionTestTarget { if (profile?.id != profileId || profile.config.type != config.type) return this val stored = profile.config val sameJump = stored.jumpHost == config.jumpHost && stored.jumpPort == config.jumpPort val sameDestination = stored.host == config.host && stored.port == config.port && (!config.type.hasJump || sameJump) return copy(config = config.copy( + authProbeHost = if (sameDestination) stored.authProbeHost else config.authProbeHost, trustedHostKey = if (sameDestination) stored.trustedHostKey else config.trustedHostKey, jumpTrustedHostKey = if (sameJump) stored.jumpTrustedHostKey else config.jumpTrustedHostKey, )) diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt index 4d38365c..57c27654 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt @@ -114,6 +114,7 @@ class NativeProxyCore( .put("sshRotationMb", config.sshRotationMb) .put("allowInvalidProxyCertificate", config.allowInvalidProxyCertificate) .put("preferHttp3", config.preferHttp3) + .put("authProbeHost", if (config.type == net.megaproxy487.model.ProxyType.HTTPS) config.authProbeHost.trim() else "") .put("profile", config.profile.name) .put("customJa3", config.customJa3.trim()) .put("dohUrl", if (config.dnsProvider.url.isNotEmpty()) config.dnsProvider.url else config.customDohUrl.trim()) diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index 2d1fc478..89651c37 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -347,7 +347,7 @@ class ProxyVpnService : VpnService() { finishStartAttempt() } if (!isStartCurrent(generation)) return - val testConfig = session.withStoredTrust(ConfigStore(this).profile(session.profileId)).config + val testConfig = session.withStoredDiagnosticSettings(ConfigStore(this).profile(session.profileId)).config val result = NativeProxyCore(this, TestDiagnosticLog::add) { isStartCurrent(generation) } .test(testConfig) { message -> runForGeneration(generation) { diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 661442d8..25e1e208 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -606,4 +606,7 @@ Проверяет каждую исходящую UDP-датаграмму. Быстрее обнаруживает смену владельца; выше задержка и нагрузка. Стандартный уровень: текущий туннель проверяет TCP при создании потока и кеширует разрешение UDP на срок до 1 секунды для каждого потока. Повторно использованный порт может временно пропускаться. Ping блокируется. Это статус сеанса, а не тест обхода другим приложением; он не скрывает наличие VPN. Текущий туннель ещё использует прежний уровень проверки. Переподключитесь для применения выбранного уровня. + Хост проверки авторизации (необязательно) + Для диагностики HTTPS по TCP. Укажите настроенный на сервере knock-хост, чтобы выявлять скрытые отказы авторизации. Если он неизвестен, оставьте поле пустым. + Укажите имя хоста без URL, пути и порта. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e747879..2e4dec7c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -607,4 +607,7 @@ Checks every outgoing UDP datagram. Detects owner changes sooner; higher latency and CPU usage. Standard level: the current tunnel checks TCP admission and caches UDP permission for up to 1 second per flow. A reused port may be temporarily accepted. Ping is blocked. This session status is not a separate-app bypass test and does not hide VPN presence. The current tunnel still uses the previous validation level. Reconnect to apply the selected level. + Authentication check host (optional) + For HTTPS over TCP diagnostics. Use the server\'s configured knock host to reveal masked authentication errors. Leave empty if unknown. + Enter a hostname without a URL, path or port. diff --git a/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt b/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt index e5bf2cf8..f9c02819 100644 --- a/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt @@ -8,6 +8,14 @@ import org.junit.Test class ConfigEditsTest { private val initial = ProxyProfile("one", "original", 0, config = ProxyConfig(host = "old.example", password = "old")) + @Test fun authenticationProbeEditPreservesConcurrentCredentialRefresh() { + val changed = initial.copy(config = initial.config.copy(authProbeHost = "knock.example")) + var saved = initial.copy(config = initial.config.copy(password = "rotated")) + ConfigEdits().profile(initial, changed).write { saved = it(saved) } + assertEquals("knock.example", saved.config.authProbeHost) + assertEquals("rotated", saved.config.password) + } + @Test fun untouchedCredentialsAndSecuritySurviveRenameWhileIntentionalConflictsUseUserValue() { val edits = ConfigEdits() val renamed = initial.copy(name = "renamed") diff --git a/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt b/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt index 987497a2..3c6c9261 100644 --- a/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt @@ -55,6 +55,17 @@ class ConfigSubscriptionIntegrationTest { private fun apply(value: PortableConfiguration, running: Boolean = false) = store.applySubscriptionSnapshot( store.subscriptionState()!!.generation, value, 0, 1000, running) + @Test fun diagnosticHostRefreshDoesNotRequestVpnReconnect() { + val original = profile("one").let { it.copy(config = it.config.copy(type = ProxyType.HTTPS, port = 443, + username = "user", password = "test-password")) } + bootstrap(original) + store.setConnectionProfile("one") + val changed = original.copy(config = original.config.copy(authProbeHost = "knock.example")) + assertTrue(apply(parseSubscriptionSnapshot(document(changed).toString(), store.profiles()), running = true)) + assertFalse(store.hasPendingReconnect()) + assertEquals("knock.example", store.profile("one")!!.config.authProbeHost) + } + @Test fun jsonRefreshPreservesLocalDnsConsentOnlyForUnchangedResolver() { val original = profile("one").let { it.copy(config = it.config.copy( dnsProvider = DnsProvider.CUSTOM, customDohUrl = "https://dns.example/dns-query")) } diff --git a/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt b/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt index aa8886a5..5ad973cf 100644 --- a/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt +++ b/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt @@ -21,6 +21,20 @@ class ProfileEditorUiTest : MainUiTestBase() { node(option).assertIsDisplayed().performClick() } + @Test fun authenticationProbeFieldSavesAndValidatesWithoutReconnect() { + val id = store.activeProfileId() + content { ProfileEditorScreen(activity, id, {}) } + val input = hasText(text(R.string.auth_probe_host)) and hasSetTextAction() + compose.onNode(hasScrollToIndexAction()).performScrollToNode(input) + compose.onNode(input).performTextReplacement("knock.example") + saved() + assertEquals("knock.example", store.profile(id)!!.config.authProbeHost) + assertFalse(store.hasPendingReconnect()) + compose.onNode(input).performTextReplacement("https://knock.example/path") + saved() + node(R.string.validation_auth_probe_host).assertExists() + } + @Test fun newSshProfileSavesCustomDohAndClearsValidationAfterCorrection() { content { ProfileEditorScreen(activity, "new", {}) } field(R.string.profile_name_optional).performTextReplacement("Custom DNS") diff --git a/app/src/test/java/net/megaproxy487/data/ConfigSchemaTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigSchemaTest.kt index 24c015f7..85da57a5 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigSchemaTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigSchemaTest.kt @@ -122,6 +122,6 @@ class ConfigSchemaTest { assertEquals(ConfigImportNotice(true, true), ConfigTransfer.importJson(root.toString()).notice) root.remove("browser") root.getJSONArray("profiles").getJSONObject(0).put("browser", JSONObject().put("knockHost", "knock.example")) - assertEquals(ConfigImportNotice(browserFields = true), ConfigTransfer.importJson(root.toString()).notice) + assertEquals(ConfigImportNotice(), ConfigTransfer.importJson(root.toString()).notice) } } diff --git a/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt index 115f4cf8..108078c5 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt @@ -58,6 +58,42 @@ class ConfigStoreIntegrationTest { .put("profiles", JSONArray().put(profile)).toString()), ) + @Test fun authenticationProbeSurvivesStorageAndDiagnosticOnlyChangeDuringStartupNeedsNoReconnect() { + val original = secretProfile() + val changed = original.copy(config = original.config.copy(authProbeHost = "knock.example")) + store.setConnectionProfile(original.id) + val started = store.connectionConfig() + store.saveProfile(changed) + assertEquals("knock.example", ConfigStore(context).profile(original.id)!!.config.authProbeHost) + assertEquals("knock.example", store.globalConnectionSettings().applyTo(changed.config).authProbeHost) + assertNull(store.reconcileStartedConfiguration(original.id, started, null)) + } + + @Test fun sharedKnockHostMergePreservesOmissionAndAcceptsExplicitDisable() { + val profile = secretProfile().let { it.copy(config = it.config.copy(authProbeHost = "knock.example")) } + store.saveProfile(profile) + val encoded = ConfigTransfer.encodeProfile(profile, true, true) + encoded.getJSONObject("proxy").remove("knockHost") + importProfile(encoded) + assertEquals("knock.example", store.profile(profile.id)!!.config.authProbeHost) + encoded.getJSONObject("proxy").put("knockHost", "") + importProfile(encoded) + assertEquals("", store.profile(profile.id)!!.config.authProbeHost) + } + + @Test fun exportedFieldsAreAllDeclaredInSharedSchema() { + store.profiles().forEach { store.saveProfile(it.copy(config = it.config.copy(host = "proxy.example"))) } + for (type in net.megaproxy487.model.ProxyType.entries) { + val profile = net.megaproxy487.model.ProxyProfile(id = type.name, colorIndex = 0, + config = net.megaproxy487.model.ProxyConfig(type = type, host = "proxy.example", + jumpHost = "jump.example", authProbeHost = if (type == net.megaproxy487.model.ProxyType.HTTPS) "knock.example" else "")) + store.saveProfile(profile) + } + val exported = ConfigTransfer.exportJson(store, true, true) + ConfigSchemas.assertValid(exported) + assertEquals(ConfigImportNotice(), ConfigImportNotices.inspect(JSONObject(exported))) + } + @Test fun mtuDefaultsPersistsAndSurvivesProfileImports() { assertEquals(1400, store.globalConnectionSettings().mtu) for (mtu in listOf(1280, 1400, 1500, 1420, 65535)) { diff --git a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt index 802dfdc7..b151b340 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt @@ -12,6 +12,30 @@ import org.junit.Assert.assertTrue import org.junit.Test class ConfigTransferTest { + @Test fun authenticationProbeUsesSharedFieldWithLegacyFallbackAndExplicitDisable() { + val config = ProxyConfig(host = "proxy.example", username = "private-user", password = "private-password", authProbeHost = "knock.example") + val encoded = ConfigTransfer.encodeProfile(ProxyProfile(id = "probe", colorIndex = 0, config = config), true, false) + val root = JSONObject().put("schema", ConfigTransfer.SCHEMA_ID).put("version", 8) + .put("profiles", JSONArray().put(encoded)) + ConfigSchemas.assertValid(root.toString()) + val imported = ConfigTransfer.importJson(root.toString()) + assertEquals("knock.example", imported.profiles.single().config.authProbeHost) + assertFalse(imported.notice.browserFields) + encoded.put("browser", JSONObject().put("knockHost", "legacy.example").put("authMode", "challenge")) + assertTrue(ConfigTransfer.importJson(root.toString()).notice.browserFields) + val reexported = ConfigTransfer.encodeProfile(imported.profiles.single(), true, false) + assertFalse(reexported.has("browser")) + assertEquals("knock.example", reexported.getJSONObject("proxy").getString("knockHost")) + encoded.getJSONObject("proxy").put("knockHost", "") + assertEquals("", ConfigTransfer.importJson(root.toString()).profiles.single().config.authProbeHost) + encoded.getJSONObject("proxy").remove("knockHost") + assertEquals("legacy.example", ConfigTransfer.importJson(root.toString()).profiles.single().config.authProbeHost) + for (value in listOf("https://knock.example/path", "host:443", true)) { + encoded.getJSONObject("proxy").put("knockHost", value) + assertTrue(runCatching { ConfigTransfer.importJson(root.toString()) }.isFailure) + } + } + @Test fun `DNS endpoints export but local security consent never does`() { for (endpoint in listOf("https://192.0.2.1/dns-query", "tcp://192.0.2.1:5353", "udp://[2001:db8::1]")) { val config = ProxyConfig(host = "proxy.example", username = "user", password = "password", diff --git a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt index 298fbd92..8a74c211 100644 --- a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt +++ b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt @@ -8,6 +8,14 @@ import org.junit.Assert.assertNull import org.junit.Test class ProxyConfigTest { + @Test fun authenticationProbeHostnameIsOptionalAndRejectsUrlsPortsAndControlCharacters() { + for (host in listOf("", "knock.example", "127.0.0.1", "localhost")) assertTrue(validAuthProbeHost(host)) + for (host in listOf("https://knock.example", "name:443", "user@host", "host/path", "host?query", + "host#fragment", "bad host", "bad\nname", "a..b", "-host", "a".repeat(64) + ".example")) { + assertFalse(validAuthProbeHost(host)) + } + } + @Test fun basicAuthRejectsColonInUsernameWhileSocksAndSshAllowIt() { for (type in ProxyType.entries) { val config = ProxyConfig(type = type, host = "proxy.example", port = type.defaultPort, diff --git a/app/src/test/java/net/megaproxy487/vpn/ConnectionTestTargetTest.kt b/app/src/test/java/net/megaproxy487/vpn/ConnectionTestTargetTest.kt index 4511e5d8..27e64853 100644 --- a/app/src/test/java/net/megaproxy487/vpn/ConnectionTestTargetTest.kt +++ b/app/src/test/java/net/megaproxy487/vpn/ConnectionTestTargetTest.kt @@ -7,6 +7,14 @@ import org.junit.Assert.* import org.junit.Test class ConnectionTestTargetTest { + @Test fun diagnosticHostUpdatesImmediatelyWithoutApplyingDraftCredentialsOrAnotherEndpoint() { + val config = ProxyConfig(host = "runtime.example", password = "runtime") + val target = ConnectionTestTarget("runtime", config) + val stored = ProxyProfile(id = "runtime", colorIndex = 0, config = config.copy(password = "draft", authProbeHost = "knock.example")) + assertEquals(config.copy(authProbeHost = "knock.example"), target.withStoredDiagnosticSettings(stored).config) + assertEquals(config, target.withStoredDiagnosticSettings(stored.copy(config = stored.config.copy(host = "new.example"))).config) + } + @Test fun failoverTestKeepsRuntimeConfigAndTrustDestinationTogether() { val runtime = ConnectionTestTarget("fallback", ProxyConfig(host = "fallback.example")) val target = connectionTestTarget(runtime) { error("selected draft must not be read") } @@ -27,14 +35,14 @@ class ConnectionTestTargetTest { password = "runtime", trustedHostKey = "old") val target = ConnectionTestTarget("runtime", config) val stored = ProxyProfile(id = "runtime", colorIndex = 0, config = config.copy(password = "draft", trustedHostKey = "approved")) - assertEquals(config.copy(trustedHostKey = "approved"), target.withStoredTrust(stored).config) - assertEquals(config, target.withStoredTrust(stored.copy(id = "selected")).config) + assertEquals(config.copy(trustedHostKey = "approved"), target.withStoredDiagnosticSettings(stored).config) + assertEquals(config, target.withStoredDiagnosticSettings(stored.copy(id = "selected")).config) } @Test fun editedHostCannotSupplyTrustForRuntimeEndpoint() { val config = ProxyConfig(type = ProxyType.SSH, host = "old.example", trustedHostKey = "old") val target = ConnectionTestTarget("runtime", config) val stored = ProxyProfile(id = "runtime", colorIndex = 0, config = config.copy(host = "new.example", trustedHostKey = "new")) - assertEquals(config, target.withStoredTrust(stored).config) + assertEquals(config, target.withStoredDiagnosticSettings(stored).config) } } diff --git a/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt b/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt index c833ed59..717f065f 100644 --- a/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt +++ b/app/src/test/java/net/megaproxy487/vpn/PrivacyLogSanitizerTest.kt @@ -44,6 +44,7 @@ class PrivacyLogSanitizerTest { "x509 certificate signed by authority Private Company CA" to "certificate", "MASQUE proxy authentication failed (status 407): private-user" to "authentication", "timeout contacting private-server" to "timeout", + "example.com check: HTTP/2 proxy CONNECT: context deadline exceeded" to "timeout", "private-peer-message" to "other", )) assertEquals(reason, nativeFailureReason(raw)) } @@ -60,6 +61,15 @@ class PrivacyLogSanitizerTest { repeat(100) { assertEquals("event=masque_session result=draining reason=goaway", limiter.filter("event=masque_session result=draining reason=goaway", 0)) } } + @Test fun authenticationProbeEventsContainOnlyAllowlistedOutcomeData() { + for (event in listOf( + "event=proxy_auth_probe result=started timeout_ms=5000", + "event=proxy_auth_probe result=rejected reason=proxy_authentication status=407 elapsed_ms=12", + "event=proxy_auth_probe result=inconclusive reason=timeout status=0 elapsed_ms=5000", + "event=proxy_auth_probe result=connect_accepted authentication=unconfirmed status=200 elapsed_ms=4", + )) assertEquals(event, PrivacyLogSanitizer.sanitize(event)) + } + @Test fun keepsStructuredDiagnosticsAndFlattensLines() { val sanitized = PrivacyLogSanitizer.sanitize( diff --git a/config-schema/android-v8.json b/config-schema/android-v8.json index 2d98d7f9..cab1ba0e 100644 --- a/config-schema/android-v8.json +++ b/config-schema/android-v8.json @@ -18,7 +18,8 @@ "host": "proxy.example.com", "port": 443, "username": "demo", - "preferHttp3": true + "preferHttp3": true, + "knockHost": "knock.example.com" } }, { diff --git a/config-schema/android-v8.schema.json b/config-schema/android-v8.schema.json index 80776b94..1c94ae8a 100644 --- a/config-schema/android-v8.schema.json +++ b/config-schema/android-v8.schema.json @@ -193,6 +193,21 @@ "type": "boolean", "default": false, "description": "Optional HTTP/3 alpha preference for HTTPS and HTTPS_JUMP profiles. Try MASQUE over QUIC on the same host and numeric UDP port; Jump uses authenticated CONNECT-UDP through the first node with remote exit-host DNS. If either node lacks HTTP/3, required settings or usable datagram/path MTU, select HTTPS over TCP for the entire chain, keeping Jump mandatory. Certificate and authentication errors are terminal. Transport stays fixed for the session. HTTPS fallback blocks ordinary UDP; DNS may still use DoH. Ignored for other types; explicit MASQUE never falls back. Unsupported QUIC fingerprints use HTTPS. Firefox Jump falls back because its DATAGRAM limit cannot fit encapsulated QUIC." + }, + "knockHost": { + "anyOf": [ + { + "const": "" + }, + { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + } + ], + "description": "Optional shared HTTPS proxy authentication-check hostname/IP, without scheme, path or port. Empty explicitly disables it. Clients prefer this key over legacy profiles[].browser.knockHost, including when empty. Android uses it only in regular HTTPS/TCP diagnostics; an unmasked CONNECT 407 proves authentication rejection, while 200, timeouts and HTML do not prove a decoy. Never bypass the proxy for this check or log addresses/credentials.", + "default": "" } }, "required": [ diff --git a/config-schema/browser-v8.json b/config-schema/browser-v8.json index fd16e757..75334e5c 100644 --- a/config-schema/browser-v8.json +++ b/config-schema/browser-v8.json @@ -17,7 +17,8 @@ "type": "HTTPS", "host": "proxy.example.com", "port": 443, - "username": "demo" + "username": "demo", + "knockHost": "knock.example.com" }, "browser": { "knockHost": "knock.example.com", diff --git a/config-schema/megaproxy-v8.schema.json b/config-schema/megaproxy-v8.schema.json index 6b27165d..9517e212 100644 --- a/config-schema/megaproxy-v8.schema.json +++ b/config-schema/megaproxy-v8.schema.json @@ -234,6 +234,21 @@ "type": "boolean", "default": false, "description": "Optional HTTP/3 alpha preference for HTTPS and HTTPS_JUMP profiles. Try MASQUE over QUIC on the same host and numeric UDP port; Jump uses authenticated CONNECT-UDP through the first node with remote exit-host DNS. If either node lacks HTTP/3, required settings or usable datagram/path MTU, select HTTPS over TCP for the entire chain, keeping Jump mandatory. Certificate and authentication errors are terminal. Transport stays fixed for the session. HTTPS fallback blocks ordinary UDP; DNS may still use DoH. Ignored for other types; explicit MASQUE never falls back. Unsupported QUIC fingerprints use HTTPS. Firefox Jump falls back because its DATAGRAM limit cannot fit encapsulated QUIC." + }, + "knockHost": { + "anyOf": [ + { + "const": "" + }, + { + "type": "string", + "maxLength": 253, + "minLength": 1, + "pattern": "^(?:[^\\s/:@?#\\\\]+|\\[?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\\]?)$" + } + ], + "description": "Optional shared HTTPS proxy authentication-check hostname/IP, without scheme, path or port. Empty explicitly disables it. Clients prefer this key over legacy profiles[].browser.knockHost, including when empty. Android uses it only in regular HTTPS/TCP diagnostics; an unmasked CONNECT 407 proves authentication rejection, while 200, timeouts and HTML do not prove a decoy. Never bypass the proxy for this check or log addresses/credentials.", + "default": "" } }, "required": [ diff --git a/config-schema/schema-lock.json b/config-schema/schema-lock.json index 5a4f6008..6748d029 100644 --- a/config-schema/schema-lock.json +++ b/config-schema/schema-lock.json @@ -1,11 +1,11 @@ { "repository": "https://github.com/andre487/MegaProxyConfig", - "commit": "58cad9f365120abb923068d89bbb835bedb2e6da", + "commit": "b565c6df3183fbb53809634b461706feef131560", "files": { - "android-v8.schema.json": "7bd831f92352a74554c6d29befaeea0c6c35a3ba73db86ffe30f3cffa49da2a0", - "megaproxy-v8.schema.json": "7591c7f910452a99dda6a5eaf9622bca41298ed290f80dd90790eae35d545b60", - "android-v8.json": "120c79fd7b00442f248ed5d317b595b4562112a2f8d1b176e597a3f91eb31670", - "browser-v8.json": "b0f9d143bbabf70aa63d2f66d37858dd9d8de5e1d91cae8d316574c7486bbbce", + "android-v8.schema.json": "d1bd1c9c7a008829a37a7be1c5ba81ae19bafa5e047cffb505154acb2346b966", + "megaproxy-v8.schema.json": "f28b38b979a6df0432054c8fe2a477decaa1d3473c10d59d2c15aa357b32ec2e", + "android-v8.json": "349397ae323f43356bbc630ae2c35ae95e1bd43f98a9d8bad4546cbf9446175f", + "browser-v8.json": "808f9f7aa590f06e107beac2ac71adcbb71526b01648aaa0c2ad6e203ef985ef", "LICENSE": "6c13885cba42ec32ee2f3720283ee61256643bd4a12fa736b4b6d9823e6e4ccc" } } diff --git a/docs/en/connection-metrics.md b/docs/en/connection-metrics.md index fbee916e..37afa70d 100644 --- a/docs/en/connection-metrics.md +++ b/docs/en/connection-metrics.md @@ -130,3 +130,5 @@ UDP forwarding is available with MASQUE, SOCKS5 and HTTPS when HTTP/3 is negotia HTTPS over TCP and SSH block ordinary UDP; DNS has its own routing rules in the [DNS guide](dns.md). Local-network bypass sends TCP directly and also bypasses UDP for MASQUE and SOCKS5. It does not provide a direct fallback after tunnel failure. + +Authentication checks use `event=proxy_auth_probe`. `result=rejected status=407` confirms a proxy authentication rejection; `result=connect_accepted authentication=unconfirmed` does not prove end-to-end connectivity. Timeouts and unexpected statuses remain `inconclusive`. A skipped probe states that the hostname was not configured; protocol selection may also skip it for HTTP/3. Events contain no addresses or credentials. See [profile settings](profiles-and-routing.md#masked-authentication-errors). diff --git a/docs/en/profiles-and-routing.md b/docs/en/profiles-and-routing.md index de813fcf..28a6f612 100644 --- a/docs/en/profiles-and-routing.md +++ b/docs/en/profiles-and-routing.md @@ -46,6 +46,16 @@ if unavailable; authentication and certificate errors are terminal. Jump needs M usable datagram MTU on both hops, otherwise the entire chain uses HTTPS/TCP. HTTPS/TCP fallback blocks ordinary UDP and shows a warning. Explicit MASQUE never falls back. +### Masked authentication errors + +Some proxies return a decoy website or stall instead of exposing an authentication failure. A timeout or HTML response alone cannot reliably identify a decoy. + +For a regular **HTTPS** profile, **Authentication check host** is optional. Enter the operator-provided knock hostname, without a URL, path or port. During HTTPS-over-TCP diagnostics the app sends an authenticated CONNECT to that hostname on port 443 through the proxy, bounded to five seconds. An unmasked `407` confirms an authentication rejection and stops the check. `200` only confirms that CONNECT was accepted; the ordinary end-to-end HTTPS test still runs. Other statuses and timeouts are inconclusive and do not get labelled as wrong credentials. + +A blank field adds no probe request. The field is imported/exported as per-profile `proxy.knockHost` (legacy `browser.knockHost` is accepted when the shared field is absent; an explicit empty shared value disables the probe); other browser options remain ignored. This probe applies to regular HTTPS over TCP, not HTTPS Jump or selected HTTP/3. Editing the diagnostic host does not reconnect a running VPN; a new check uses it for the same runtime endpoint while preserving runtime credentials. It adds no per-packet checks. + +The shared diagnostic log records `proxy_auth_probe` start, skipped/accepted/rejected/inconclusive outcome, HTTP status, elapsed time and safe failure class. It contains neither the probe/proxy hostname nor IPs, usernames, passwords or response bodies. + ### SOCKS5 Leave both authentication fields empty, or supply a username and password of 1–255 UTF-8 bytes diff --git a/docs/ru/connection-metrics.md b/docs/ru/connection-metrics.md index 6c4eac15..bf4fa337 100644 --- a/docs/ru/connection-metrics.md +++ b/docs/ru/connection-metrics.md @@ -136,3 +136,5 @@ UDP передаётся через MASQUE, SOCKS5 и HTTPS при соглас HTTPS поверх TCP и SSH блокируют обычный UDP; для DNS действуют отдельные правила из [руководства DNS](dns.md). Обход локальных сетей отправляет TCP напрямую, а в MASQUE и SOCKS5 — также UDP. Это не прямой резервный маршрут при отказе туннеля. + +Проверка авторизации пишет `event=proxy_auth_probe`. `result=rejected status=407` подтверждает отказ авторизации прокси; `result=connect_accepted authentication=unconfirmed` не доказывает сквозную связность. Таймауты и неожиданные статусы остаются `inconclusive`. При пропуске указано, что хост не настроен; проверка также пропускается при выборе HTTP/3. Адреса и учётные данные в события не входят. См. [настройку профиля](profiles-and-routing.md#скрытые-ошибки-авторизации). diff --git a/docs/ru/profiles-and-routing.md b/docs/ru/profiles-and-routing.md index 3f012d38..702e9f94 100644 --- a/docs/ru/profiles-and-routing.md +++ b/docs/ru/profiles-and-routing.md @@ -48,6 +48,16 @@ TLS приложений остаётся сквозным: MegaProxy не ус нужны MASQUE и подходящий MTU датаграмм на обоих звеньях, иначе вся цепочка использует HTTPS/TCP. При переходе на HTTPS/TCP обычный UDP блокируется с предупреждением. Явный MASQUE не использует fallback. +### Скрытые ошибки авторизации + +Некоторые прокси вместо отказа авторизации показывают сайт-приманку (decoy) или задерживают ответ. По одному таймауту или HTML нельзя достоверно определить наличие decoy. + +Для обычного профиля **HTTPS** доступно необязательное поле **Хост проверки авторизации**. Укажите предоставленный оператором knock-хост без URL, пути и порта. При диагностике HTTPS по TCP приложение отправляет через прокси CONNECT к этому хосту на порт 443 с учётными данными профиля; проверка ограничена пятью секундами. Явный `407` подтверждает отказ авторизации и останавливает тест. `200` означает только принятие CONNECT; после него выполняется обычная сквозная проверка HTTPS. Остальные статусы и таймауты не доказывают ошибку логина или пароля. + +При пустом поле дополнительного запроса нет. Поле импортируется и экспортируется как `proxy.knockHost` профиля (старый `browser.knockHost` читается при отсутствии общего поля; пустое общее поле отключает проверку); остальные браузерные настройки игнорируются. Проверка относится к обычному HTTPS по TCP, а не HTTPS с Jump или выбранному HTTP/3. Изменение диагностического хоста не переподключает работающий VPN; новый тест использует его для того же действующего адреса прокси, сохраняя действующие учётные данные. Дополнительных проверок пакетов нет. + +Общий журнал диагностики содержит события `proxy_auth_probe`: начало, пропуск, принятие CONNECT, отказ или неопределённый результат, HTTP-статус, время и безопасный класс ошибки. Хосты проверки и прокси, IP, логины, пароли и содержимое ответов в эти события не попадают. + ### SOCKS5 Оставьте оба поля авторизации пустыми или задайте логин и пароль по 1–255 байт UTF-8 каждый. diff --git a/native/mobile/auth_probe.go b/native/mobile/auth_probe.go new file mode 100644 index 00000000..88f59139 --- /dev/null +++ b/native/mobile/auth_probe.go @@ -0,0 +1,69 @@ +package mobile + +import ( + "context" + "errors" + "net" + "net/http" + "regexp" + "strings" + "time" +) + +var authProbeLabel = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$`) + +func validAuthProbeHost(host string) bool { + if host == "" { + return true + } + if strings.HasPrefix(host, "[") != strings.HasSuffix(host, "]") { + return false + } + if net.ParseIP(strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")) != nil { + return true + } + if len(host) > 253 { + return false + } + for _, label := range strings.Split(host, ".") { + if len(label) > 63 || !authProbeLabel.MatchString(label) { + return false + } + } + return true +} + +// Only a configured, unmasked 407 proves an authentication rejection. Neither +// a timeout nor a 200 response proves that a decoy is (or is not) in use. +func checkProxyAuthentication(ctx context.Context, host string, connect func(context.Context, string) (net.Conn, error), reporter Reporter) error { + if host == "" { + report(reporter, "event=proxy_auth_probe result=skipped reason=not_configured") + return nil + } + report(reporter, "event=proxy_auth_probe result=started timeout_ms=5000") + started := time.Now() + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + connection, err := connect(probeCtx, net.JoinHostPort(strings.TrimSuffix(strings.TrimPrefix(host, "["), "]"), "443")) + if connection != nil { + // HTTP/1 TLS close_notify must not extend the bounded probe; HTTP/2 + // deadlines affect only this stream and preserve the shared session. + _ = connection.SetDeadline(time.Now()) + _ = connection.Close() + } + var rejected *httpConnectStatusError + if errors.As(err, &rejected) && rejected.status == http.StatusProxyAuthRequired { + report(reporter, "event=proxy_auth_probe result=rejected reason=proxy_authentication status=407 elapsed_ms=%d", time.Since(started).Milliseconds()) + return err + } + if err != nil { + status := 0 + if rejected != nil { + status = rejected.status + } + report(reporter, "event=proxy_auth_probe result=inconclusive reason=%s status=%d elapsed_ms=%d", errorClass(err), status, time.Since(started).Milliseconds()) + return nil + } + report(reporter, "event=proxy_auth_probe result=connect_accepted authentication=unconfirmed status=200 elapsed_ms=%d", time.Since(started).Milliseconds()) + return nil +} diff --git a/native/mobile/auth_probe_test.go b/native/mobile/auth_probe_test.go new file mode 100644 index 00000000..e7dfeae4 --- /dev/null +++ b/native/mobile/auth_probe_test.go @@ -0,0 +1,117 @@ +package mobile + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" +) + +func TestAuthenticationProbeReveals407ThroughProductionHTTPSDialer(t *testing.T) { + for _, h2 := range []bool{false, true} { + t.Run(strconv.FormatBool(h2), func(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != "CONNECT" || r.Host != "127.0.0.1:443" { + t.Errorf("probe did not use configured target") + w.WriteHeader(502) + return + } + expected := "Basic " + base64.StdEncoding.EncodeToString([]byte("private-user:private-password")) + if r.Header.Get("Proxy-Authorization") != expected { + t.Error("probe did not use profile authentication") + } + w.WriteHeader(407) + })) + server.EnableHTTP2 = h2 + server.StartTLS() + defer server.Close() + host, port, _ := net.SplitHostPort(server.Listener.Addr().String()) + number, _ := strconv.Atoi(port) + c := config{Type: "HTTPS", Host: "localhost", DialHost: host, Port: number, Username: "private-user", Password: "private-password", Profile: "CHROME_ANDROID", AllowInvalidProxyCertificate: true, DoHURL: "https://unused.invalid/dns-query", BypassLocalNetworks: true, AuthProbeHost: "127.0.0.1"} + raw, _ := json.Marshal(c) + logs := &diagnosticRecorder{} + _, err := TestConnection(string(raw), &jumpTestProtector{}, logs) + var rejected *httpConnectStatusError + if !errors.As(err, &rejected) || rejected.status != 407 { + t.Fatalf("wanted authentication rejection, got %v", err) + } + if requests.Load() != 1 { + t.Fatalf("diagnostics continued after 407: %d requests", requests.Load()) + } + if !strings.Contains(logs.text(), "event=proxy_auth_probe result=rejected reason=proxy_authentication status=407") { + t.Fatal("missing structured authentication failure") + } + for _, private := range []string{"127.0.0.1", "localhost", "2001:db8::1", "[2001:db8::1]", "private-user", "private-password", "unused.invalid"} { + if strings.Contains(logs.text(), private) { + t.Fatal("private field leaked into probe logs") + } + } + }) + } +} + +func TestAuthenticationProbeDoesNotGuessFromTimeoutOrUnexpectedStatus(t *testing.T) { + for _, tc := range []struct { + err error + want string + }{ + {context.DeadlineExceeded, "result=inconclusive reason=timeout status=0"}, + {&httpConnectStatusError{status: 404}, "result=inconclusive reason=connect_rejected status=404"}, + {errors.New("private-user private-password secret.example 192.0.2.1"), "result=inconclusive reason=other status=0"}, + } { + logs := &diagnosticRecorder{} + err := checkProxyAuthentication(context.Background(), "probe.example", func(ctx context.Context, target string) (net.Conn, error) { + if _, ok := ctx.Deadline(); !ok { + t.Fatal("probe must be bounded") + } + return nil, tc.err + }, logs) + if err != nil || !strings.Contains(logs.text(), tc.want) { + t.Fatalf("probe guessed an authentication error: %v %s", err, logs.text()) + } + for _, private := range []string{"private-user", "private-password", "secret.example", "192.0.2.1", "probe.example"} { + if strings.Contains(logs.text(), private) { + t.Fatal("peer-controlled data entered probe logs") + } + } + } + calls := 0 + if err := checkProxyAuthentication(context.Background(), "", func(context.Context, string) (net.Conn, error) { calls++; return nil, nil }, nil); err != nil || calls != 0 { + t.Fatal("disabled probe opened a connection") + } +} + +func TestAuthenticationProbeAcceptedConnectRemainsUnconfirmedAndClosesStream(t *testing.T) { + left, right := net.Pipe() + defer right.Close() + logs := &diagnosticRecorder{} + err := checkProxyAuthentication(context.Background(), "probe.example", func(context.Context, string) (net.Conn, error) { return left, nil }, logs) + if err != nil || !strings.Contains(logs.text(), "result=connect_accepted authentication=unconfirmed status=200") { + t.Fatal("CONNECT 200 must not prove authentication") + } + if _, err := right.Write([]byte{1}); err == nil { + t.Fatal("probe stream was not closed") + } +} + +func TestAuthenticationProbeHostnameValidation(t *testing.T) { + for _, host := range []string{"", "knock.example", "127.0.0.1", "localhost", "2001:db8::1", "[2001:db8::1]"} { + if !validAuthProbeHost(host) { + t.Fatal("valid hostname rejected") + } + } + for _, host := range []string{"https://knock.example", "name:443", "[2001:db8::1", "2001:db8::1]", "user@host", "host/path", "host\\path", "host?query", "host#fragment", "bad host", "bad\nname", "a..b", "-host", strings.Repeat("a", 64) + ".example", strings.Repeat("a.", 127) + "aa"} { + if validAuthProbeHost(host) { + t.Fatal("invalid hostname accepted") + } + } +} diff --git a/native/mobile/config.go b/native/mobile/config.go index 4cea1cc0..31d3f3a8 100644 --- a/native/mobile/config.go +++ b/native/mobile/config.go @@ -14,6 +14,7 @@ import ( type config struct { AllowInvalidDoHCertificate bool `json:"allowInvalidDohCertificate"` AllowInsecureDNS bool `json:"allowInsecureDns"` + AuthProbeHost string `json:"authProbeHost"` PreferHTTP3 bool `json:"preferHttp3"` Type string `json:"type"` Host string `json:"host"` @@ -65,6 +66,10 @@ func parseConfig(raw string) (config, error) { c.SSHRotationMB < 0 || c.SSHRotationMB > 10240 { return c, errors.New("invalid SSH keepalive or rotation limit") } + c.AuthProbeHost = strings.TrimSpace(c.AuthProbeHost) + if !validAuthProbeHost(c.AuthProbeHost) { + return c, errors.New("invalid diagnostic probe hostname") + } c.Host = strings.TrimSpace(c.Host) c.DialHost = strings.TrimSpace(c.DialHost) if c.Type == "" { diff --git a/native/mobile/diagnostics.go b/native/mobile/diagnostics.go index 0203970b..bc2799c6 100644 --- a/native/mobile/diagnostics.go +++ b/native/mobile/diagnostics.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net" + "net/http" "strings" "sync/atomic" @@ -57,6 +58,13 @@ func errorClass(err error) string { } return "connect_rejected" } + var httpRejected *httpConnectStatusError + if errors.As(err, &httpRejected) { + if httpRejected.status == http.StatusProxyAuthRequired { + return "proxy_authentication" + } + return "connect_rejected" + } if errors.Is(err, io.EOF) { return "eof" } diff --git a/native/mobile/dialer.go b/native/mobile/dialer.go index caaf9038..ff9fb608 100644 --- a/native/mobile/dialer.go +++ b/native/mobile/dialer.go @@ -347,7 +347,7 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( } if response.StatusCode != http.StatusOK { _ = response.Body.Close() - err = fmt.Errorf("proxy CONNECT returned status %d", response.StatusCode) + err = &httpConnectStatusError{status: response.StatusCode} report(d.reporter, "event=connection conn=%d mode=proxy stage=connect_response result=rejected status=%d status_class=%dxx", connectionID, response.StatusCode, response.StatusCode/100) return nil, err } @@ -406,14 +406,14 @@ func (d *httpsConnectDialer) trackConnection(conn net.Conn, id uint64) net.Conn return &diagnosticConn{Conn: conn, connectionID: id, reporter: d.reporter, stats: d.stats} } -type http2ConnectStatusError struct{ status int } +type httpConnectStatusError struct{ status int } -func (e *http2ConnectStatusError) Error() string { - return fmt.Sprintf("HTTP/2 proxy CONNECT returned status %d", e.status) +func (e *httpConnectStatusError) Error() string { + return fmt.Sprintf("proxy CONNECT returned status %d", e.status) } func shouldFallbackToHTTP1(err error) bool { - var statusError *http2ConnectStatusError + var statusError *httpConnectStatusError return errors.As(err, &statusError) && (statusError.status == http.StatusMethodNotAllowed || statusError.status == http.StatusNotImplemented) } @@ -511,7 +511,7 @@ func (d *httpsConnectDialer) openHTTP2Tunnel(ctx context.Context, session *http2 if err != nil { 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} + return nil, &httpConnectStatusError{status: status} } report(d.reporter, "event=connection conn=%d mode=proxy protocol=http2 stage=connect_response result=failed reason=%s reused_session=%t", connectionID, errorClass(err), reused) return nil, fmt.Errorf("HTTP/2 proxy CONNECT: %w", err) diff --git a/native/mobile/http2_connect_test.go b/native/mobile/http2_connect_test.go index d9ee245d..fe4ad370 100644 --- a/native/mobile/http2_connect_test.go +++ b/native/mobile/http2_connect_test.go @@ -146,7 +146,7 @@ func TestHTTP2RejectedTargetPreservesSession(t *testing.T) { session := &http2ConnectSession{client: client} d := &httpsConnectDialer{h2Session: session} _, err := d.connectTarget(context.Background(), "example.com:443") - var rejected *http2ConnectStatusError + var rejected *httpConnectStatusError if !errors.As(err, &rejected) || rejected.status != http.StatusBadGateway { t.Fatalf("error = %v", err) } diff --git a/native/mobile/test_connection.go b/native/mobile/test_connection.go index 3986354f..fe2a8100 100644 --- a/native/mobile/test_connection.go +++ b/native/mobile/test_connection.go @@ -63,7 +63,10 @@ func TestConnection(rawConfig string, protector Protector, reporter Reporter) (s connect, testReporter = preferred.connectTarget, reporter defer preferred.Close() } else if c.isHTTPS() { - dialer := &httpsConnectDialer{config: c, protector: protector, reporter: reporter} + testConfig := c + // Diagnostics must reach the proxy even for a private probe hostname. + testConfig.BypassLocalNetworks = false + dialer := &httpsConnectDialer{config: testConfig, protector: protector, reporter: reporter} connect, testReporter = dialer.connectTarget, dialer.reporter defer dialer.Close() } else if c.Type == "SOCKS5" { @@ -81,7 +84,15 @@ func TestConnection(rawConfig string, protector Protector, reporter Reporter) (s defer dialer.invalidate() } report(reporter, "event=connection_test result=started") + if c.Type == "HTTPS" { + if preferred != nil { + report(reporter, "event=proxy_auth_probe result=skipped reason=transport_http3") + } else if err := checkProxyAuthentication(ctx, c.AuthProbeHost, connect, reporter); err != nil { + return "", err + } + } if _, err := testHTTPSGet(ctx, connect, testReporter, "example.com", "/", false); err != nil { + report(reporter, "event=connection_test stage=https_check result=failed reason=%s", errorClass(err)) return "", fmt.Errorf("example.com check: %w", err) } report(reporter, "event=connection_test stage=https_check result=success")