android: resilience of F_Profile at work: backup ports, reserve server, bridge while whitelists are on

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 15:33:36 +07:00
1 parent e7b801d584
commit 5ca60d7066
6 files changed
+230

No files matched your search

@@ -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();
@@ -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);
@@ -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 */
@@ -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 <http://www.fsf.org/copyleft/gpl.txt>.
*
* 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()
}
@@ -1223,4 +1223,12 @@
<string name="profile_update_keep">Останутся без изменений: запасные порты, резервный сервер, мост, DNS, правила и приложения.</string>
<string name="profile_update_apply">Применить и переподключить</string>
<string name="server_just_now">только что</string>
<string name="event_reserve_title">Переключились на резерв «%1$s»</string>
<string name="event_reserve_text">«%1$s» не ответил.</string>
<string name="event_backup_port_title">Подключились через порт %1$d</string>
<string name="event_backup_port_text">Основной порт не ответил, сработал запасной.</string>
<string name="event_bridge_on_title">Включили мост «%1$s»</string>
<string name="event_bridge_on_text">Оператор включил белые списки: российские сайты работают, зарубежные серверы — нет.</string>
<string name="event_bridge_off_title">Вернулись на «%1$s»</string>
<string name="event_bridge_off_text">Белые списки сняли.</string>
</resources>
@@ -1209,4 +1209,12 @@
<string name="profile_update_keep">Unchanged: backup ports, reserve server, bridge, DNS, rules and apps.</string>
<string name="profile_update_apply">Apply and reconnect</string>
<string name="server_just_now">just now</string>
<string name="event_reserve_title">Switched to the reserve «%1$s»</string>
<string name="event_reserve_text">«%1$s» did not answer.</string>
<string name="event_backup_port_title">Connected through port %1$d</string>
<string name="event_backup_port_text">The main port did not answer, a backup port worked.</string>
<string name="event_bridge_on_title">Switched to the bridge «%1$s»</string>
<string name="event_bridge_on_text">The operator turned on whitelists: Russian sites work, foreign servers do not.</string>
<string name="event_bridge_off_title">Back to «%1$s»</string>
<string name="event_bridge_off_text">The whitelists are off.</string>
</resources>