Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
17 changes: 14 additions & 3 deletions app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/net/megaproxy487/ProfilesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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,
))
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/net/megaproxy487/data/ConfigEdits.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ internal fun ConfigEdits<ProxyProfile>.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)) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
12 changes: 8 additions & 4 deletions app/src/main/java/net/megaproxy487/data/ConfigStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand All @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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(),
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,6 +37,7 @@ data class PortableConfiguration(
val subscription: ConfigSubscription? = null,
val globalSettingFields: Set<String>? = null,
val diagnosticLogLimitPresent: Boolean = true,
val knockHostPresent: Set<String>? = null,
) {
internal fun mergeGlobalSettings(current: GlobalConnectionSettings): GlobalConnectionSettings {
val incoming = globalConnectionSettings ?: return current
Expand Down Expand Up @@ -180,6 +182,11 @@ object ConfigTransfer {
}
} else null,
skippedProfiles = decoded.count(Result<ProxyProfile>::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" }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading