diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index 62c6dc6e2..4d17fb59f 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -270,6 +270,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe if (profile != null) { profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD)); + /* a backup port of F_Profile, for this attempt only */ + int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0); + if (port > 0) + { + profile.setPort(port); + } PreferenceManager.getDefaultSharedPreferences(this).edit() .putString(Constants.PREF_MRU_VPN_PROFILE, profile.getUUID().toString()) .apply(); diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java index c7bd65fb3..6e276728d 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/VpnStateService.java @@ -35,6 +35,7 @@ import org.strongswan.android.ui.widget.ShxWidgetState; import org.strongswan.android.data.VpnProfileDataSource; import org.strongswan.android.data.VpnType; import org.strongswan.android.logic.events.Events; +import org.strongswan.android.logic.profiles.Resilience; import org.strongswan.android.logic.imc.ImcState; import org.strongswan.android.logic.imc.RemediationInstruction; import org.strongswan.android.logic.connectivity.WhiteListManager; @@ -448,6 +449,10 @@ public class VpnStateService extends Service { uuid = mProfile.getUUID().toString(); } + if (fromScratch) + { /* a user's connect starts a new resilience attempt */ + Resilience.onConnect(uuid, profileInfo); + } /* double-start guard: while a healthy connect attempt for the same * profile is in flight (or was just issued and the service has not * reached startConnection() yet) a second start intent only churns @@ -456,6 +461,7 @@ public class VpnStateService extends Service * here within 20 seconds. Deliberate retries during an error backoff * (mError set) and requests for another profile always pass. */ if (uuid != null && uuid.equals(mLastConnectUuid) && mError == ErrorState.NO_ERROR + && !profileInfo.getBoolean(Resilience.KEY_INTERNAL, false) && (mState == State.CONNECTING || ((mState == State.DISABLED || mState == State.DISCONNECTING) && SystemClock.uptimeMillis() - mLastConnectIssued < CONNECT_DEDUPE_MS))) @@ -692,6 +698,7 @@ public class VpnStateService extends Service if (state == State.CONNECTED) { Events.connectionRestored(VpnStateService.this); + Resilience.onConnected(VpnStateService.this); } if (state == State.CONNECTING) { /* reentered while the daemon reestablishes the tunnel; bound @@ -725,6 +732,14 @@ public class VpnStateService extends Service { if (VpnStateService.this.mError != error) { + /* the server does not answer: backup ports, then the + * reserve server, before the regular backoff */ + if (VpnStateService.this.mError == ErrorState.NO_ERROR + && (error == ErrorState.UNREACHABLE || error == ErrorState.LOOKUP_FAILED) + && Resilience.onUnreachable(VpnStateService.this)) + { + return false; + } if (VpnStateService.this.mError == ErrorState.NO_ERROR) { setRetryTimer(error); diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt index c905da6ff..db2dbe50c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/connectivity/WhiteListManager.kt @@ -442,10 +442,12 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt if (before.status != WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.ACTIVE) { Events.whitelist(context, true) + android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, true) } } else if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE) { Events.whitelist(context, false) + android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, false) } } if (machine.status == WhiteListStatus.ACTIVE) { /* keep watching for the whitelist to clear, at a low rate */ diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/Resilience.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/Resilience.kt new file mode 100644 index 000000000..e9d072726 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/Resilience.kt @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic.profiles + +import android.content.Context +import android.os.Bundle +import org.strongswan.android.R +import org.strongswan.android.data.VpnProfileDataSource +import org.strongswan.android.data.VpnProfileSource +import org.strongswan.android.data.VpnType +import org.strongswan.android.logic.DiagLog +import org.strongswan.android.logic.VpnStateService +import org.strongswan.android.logic.events.EventCategory +import org.strongswan.android.logic.events.EventKind +import org.strongswan.android.logic.events.EventLog +import java.util.UUID + +/** + * The resilience settings of F_Profile at work. When the server does not + * answer, the backup ports are tried in turn, then the reserve server; only + * then the regular retry backoff takes over. While the operator whitelists + * are on, a profile with «Bridge for whitelists» switches to its bridge and + * comes back when they clear. + * + * Called on the main thread by [VpnStateService] and the whitelist detector. + */ +object Resilience +{ + private const val TAG = "Resilience" + /** marks the connects started here (they keep the attempt state) */ + const val KEY_INTERNAL = "org.strongswan.android.resilience.INTERNAL" + /** port used instead of the profile's for this connect */ + const val KEY_PORT_OVERRIDE = "org.strongswan.android.resilience.PORT" + + /* the profile the user asked for and where the attempt stands */ + private var origin: String? = null + private var portIndex = -1 + private var onReserve = false + /* set while switched to the bridge: the profile to come back to */ + private var bridgeOrigin: String? = null + + /** Every connect request; a user's connect starts a new attempt. */ + @JvmStatic + fun onConnect(uuid: String?, info: Bundle) + { + if (info.getBoolean(KEY_INTERNAL, false)) + { + return + } + origin = uuid + portIndex = -1 + onReserve = false + bridgeOrigin = null + } + + /** + * The server of the current attempt does not answer. Returns true when + * another attempt was started (the regular retry stays off). + */ + @JvmStatic + fun onUnreachable(service: VpnStateService): Boolean + { + val current = service.profile ?: return false + val start = origin ?: current.getUUID().toString().also { origin = it } + val extra = ProfileExtras.get(service, start) + if (!onReserve && portIndex + 1 < extra.backupPorts.size) + { + portIndex++ + val port = extra.backupPorts[portIndex] + DiagLog.i(TAG, "server does not answer, trying backup port $port") + connect(service, start, current.password.takeIf { current.getUUID().toString() == start }, port) + return true + } + val reserve = extra.reserveUuid + if (!onReserve && reserve != null && reserve != start) + { + val profile = load(service, reserve) ?: return false + if (profile.vpnType.has(VpnType.VpnTypeFeature.USER_PASS) && profile.password == null) + { /* the reserve needs a password nobody can type in now */ + DiagLog.i(TAG, "reserve ${profile.name} needs a password, not switching") + return false + } + onReserve = true + DiagLog.i(TAG, "server does not answer, switching to the reserve ${profile.name}") + EventLog.getInstance(service).record( + EventKind.INFO, EventCategory.CONNECTION, + service.getString(R.string.event_reserve_title, profile.name ?: profile.gateway), + service.getString(R.string.event_reserve_text, load(service, start)?.name ?: ""), key = "reserve:$start", + ) + connect(service, reserve, null, null) + return true + } + return false + } + + /** Connected: tell when it took a backup port. */ + @JvmStatic + fun onConnected(service: VpnStateService) + { + val start = origin ?: return + if (!onReserve && portIndex >= 0) + { + val port = ProfileExtras.get(service, start).backupPorts.getOrNull(portIndex) ?: return + EventLog.getInstance(service).record( + EventKind.INFO, EventCategory.CONNECTION, service.getString(R.string.event_backup_port_title, port), + service.getString(R.string.event_backup_port_text), key = "port:$start", + ) + } + } + + /** The operator whitelists came or went (WhiteListManager). */ + @JvmStatic + fun onWhitelist(context: Context, active: Boolean) + { + val service = VpnStateService.getLiveInstance() ?: return + val current = service.profile?.getUUID()?.toString() ?: return + if (active) + { + val extra = ProfileExtras.get(context, current) + val bridge = extra.bridgeUuid?.takeIf { extra.bridgeEnabled && it != current } ?: return + if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR) + { /* the VPN is off on purpose */ + return + } + val profile = load(context, bridge) ?: return + if (profile.vpnType.has(VpnType.VpnTypeFeature.USER_PASS) && profile.password == null) + { + return + } + DiagLog.i(TAG, "whitelists on, switching to the bridge ${profile.name}") + EventLog.getInstance(context).record( + EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_bridge_on_title, profile.name ?: profile.gateway), + context.getString(R.string.event_bridge_on_text), key = "bridge", + ) + connect(service, bridge, null, null) + bridgeOrigin = current + } + else + { + val back = bridgeOrigin ?: return + bridgeOrigin = null + if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR) + { + return + } + val profile = load(context, back) ?: return + DiagLog.i(TAG, "whitelists off, back to ${profile.name}") + EventLog.getInstance(context).resolve("bridge") + EventLog.getInstance(context).record( + EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_bridge_off_title, profile.name ?: profile.gateway), + context.getString(R.string.event_bridge_off_text), key = "bridge-off", + ) + connect(service, back, null, null) + } + } + + private fun connect(service: VpnStateService, uuid: String, password: String?, port: Int?) + { + val info = Bundle() + info.putString(VpnProfileDataSource.KEY_UUID, uuid) + password?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) } + port?.let { info.putInt(KEY_PORT_OVERRIDE, it) } + info.putBoolean(KEY_INTERNAL, true) + service.connect(info, true) + } + + private fun load(context: Context, uuid: String) = runCatching { + val source = VpnProfileSource(context) + source.open() + try + { + source.getVpnProfile(UUID.fromString(uuid)) + } + finally + { + source.close() + } + }.getOrNull() +} diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index b64b99cbe..579dba3a7 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1223,4 +1223,12 @@ Останутся без изменений: запасные порты, резервный сервер, мост, DNS, правила и приложения. Применить и переподключить только что + Переключились на резерв «%1$s» + «%1$s» не ответил. + Подключились через порт %1$d + Основной порт не ответил, сработал запасной. + Включили мост «%1$s» + Оператор включил белые списки: российские сайты работают, зарубежные серверы — нет. + Вернулись на «%1$s» + Белые списки сняли. diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 9e1831f8b..78cda60ab 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1209,4 +1209,12 @@ Unchanged: backup ports, reserve server, bridge, DNS, rules and apps. Apply and reconnect just now + Switched to the reserve «%1$s» + «%1$s» did not answer. + Connected through port %1$d + The main port did not answer, a backup port worked. + Switched to the bridge «%1$s» + The operator turned on whitelists: Russian sites work, foreign servers do not. + Back to «%1$s» + The whitelists are off.