android: no unattended start of a profile without a saved password (watchdog, network rules, scenarios, reserve, bridge): an event that opens the server instead; «password missing» ends the watchdog expectation; resume and restore keep a typed password; warning on the server page

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 22:01:41 +07:00
1 parent df1575fe27
commit e3e6d51f6f
14 files changed
+189 -9

No files matched your search

@@ -300,6 +300,9 @@ object PauseManager
{
val extras = Bundle()
extras.putString(VpnProfileDataSource.KEY_UUID, profileUuid)
/* a password typed for the paused session goes along with it */
service.profile?.takeIf { it.getUUID().toString() == profileUuid }?.password
?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
service.connect(extras, true)
}
return
@@ -753,6 +753,13 @@ public class VpnStateService extends Service
{ /* repeats while retrying are merged into one event */
Events.connectionError(VpnStateService.this, error);
}
if (error == ErrorState.PASSWORD_MISSING)
{ /* only a person can type the password: the watchdog must not
* bring this attempt back after the process restarts */
PreferenceManager.getDefaultSharedPreferences(VpnStateService.this).edit()
.remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
}
return true;
}
return false;
@@ -30,6 +30,8 @@ import android.util.Log;
import androidx.preference.PreferenceManager;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnProfileSource;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.logic.autoconnect.AutoConnectManager;
import org.strongswan.android.logic.connectivity.WhiteListManager;
import org.strongswan.android.utils.Constants;
@@ -239,6 +241,19 @@ public class WatchdogReceiver extends BroadcastReceiver
return;
}
}
/* a profile that signs in with an unsaved password would only end
* in «password missing» after every restore: drop the expectation
* and say so instead */
VpnProfile expected = loadProfile(context, uuid);
if (expected != null && org.strongswan.android.logic.profiles.ProfileExtrasKt.needsPassword(expected))
{
DiagLog.i(TAG, "expected profile needs a password, not restoring");
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
org.strongswan.android.logic.events.Events.restoreNeedsPassword(context, uuid,
expected.getName() != null ? expected.getName() : expected.getGateway());
return;
}
/* either the process died (the receiver runs in a fresh one) or the
* service is idle while a connection is expected - restart it. In
* the rare second case this may duplicate a pending in-process retry
@@ -269,6 +284,24 @@ public class WatchdogReceiver extends BroadcastReceiver
}
}
private static VpnProfile loadProfile(Context context, String uuid)
{
VpnProfileSource source = new VpnProfileSource(context);
try
{
source.open();
return source.getVpnProfile(java.util.UUID.fromString(uuid));
}
catch (Exception e)
{
return null;
}
finally
{
source.close();
}
}
/**
* Profile the trusted-networks auto session should use: the configured
* auto-connect profile, falling back to the most recently used one.
@@ -349,6 +349,12 @@ class AutoConnectManager private constructor(
Log.e(TAG, "VpnService.prepare failed", e)
true
}
val livePassword = svc.profile?.takeIf { it.getUUID().toString() == uuid }?.password
if (!needsConsent && livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
{ /* nobody is there to type the password */
autoSession.set(false)
return
}
if (!needsConsent)
{
DiagLog.i(TAG, "auto session starting, profile=$uuid")
@@ -358,6 +364,7 @@ class AutoConnectManager private constructor(
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
val extras = Bundle()
extras.putString(VpnProfileDataSource.KEY_UUID, uuid)
livePassword?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
/* automatic start: keep the previous session's charon log */
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
svc.connect(extras, true)
@@ -68,6 +68,8 @@ enum class EventAction
SET_APP_DIRECT,
/** show the link update of the profile [ShxEvent.target] (F_ProfileUpdate) */
PROFILE_UPDATE,
/** open the server page of the profile [ShxEvent.target] */
OPEN_SERVER,
}
data class ShxEvent(
@@ -142,4 +142,35 @@ object Events
context.getString(R.string.event_update_text), EventAction.INSTALL_UPDATE, "update:$versionCode",
)
}
/** The watchdog did not bring back [name]: a password would be needed. */
@JvmStatic
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
{
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_restore_pw_title, name),
context.getString(R.string.event_restore_pw_text), EventAction.OPEN_SERVER, "restore-pw:$uuid", uuid,
)
}
/** The reserve or bridge [target] of [origin] has no saved password, so no switch. */
@JvmStatic
fun switchNeedsPassword(context: Context, targetUuid: String, target: String, origin: String, bridge: Boolean)
{
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_switch_pw_title, target),
context.getString(if (bridge) R.string.event_switch_pw_bridge else R.string.event_switch_pw_reserve, origin),
EventAction.OPEN_SERVER, "switch-pw:$targetUuid", targetUuid,
)
}
/** A network rule or a scenario could not start [name]: no saved password. */
@JvmStatic
fun autoNeedsPassword(context: Context, uuid: String, name: String)
{
EventLog.getInstance(context).record(
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_auto_pw_title, name),
context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid,
)
}
}
@@ -204,3 +204,35 @@ object ProfileExtras
)
}
}
/** The profile signs in with a password and none is saved: nobody can start it unattended. */
fun org.strongswan.android.data.VpnProfile.needsPassword(): Boolean =
vpnType.has(org.strongswan.android.data.VpnType.VpnTypeFeature.USER_PASS) && password.isNullOrEmpty()
/**
* For unattended starts (network rules, scenarios): true when [uuid] signs in
* with an unsaved password - the start would only end in «password missing».
* Records one event pointing at the server.
*/
fun blockedByPassword(context: android.content.Context, uuid: String): Boolean
{
val profile = runCatching {
val source = org.strongswan.android.data.VpnProfileSource(context)
source.open()
try
{
source.getVpnProfile(java.util.UUID.fromString(uuid))
}
finally
{
source.close()
}
}.getOrNull() ?: return false
if (!profile.needsPassword())
{
return false
}
org.strongswan.android.logic.DiagLog.i("ProfileExtras", "${profile.name} needs a password, no unattended start")
org.strongswan.android.logic.events.Events.autoNeedsPassword(context, uuid, profile.name ?: profile.gateway)
return true
}
@@ -25,6 +25,7 @@ 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 org.strongswan.android.logic.events.Events
import java.util.UUID
/**
@@ -87,9 +88,11 @@ object Resilience
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 */
if (profile.needsPassword())
{ /* the reserve needs a password nobody can type in now: say so once */
DiagLog.i(TAG, "reserve ${profile.name} needs a password, not switching")
onReserve = true
Events.switchNeedsPassword(service, reserve, profile.name ?: profile.gateway, load(service, start)?.name ?: "", bridge = false)
return false
}
onReserve = true
@@ -135,8 +138,10 @@ object Resilience
return
}
val profile = load(context, bridge) ?: return
if (profile.vpnType.has(VpnType.VpnTypeFeature.USER_PASS) && profile.password == null)
if (profile.needsPassword())
{
DiagLog.i(TAG, "bridge ${profile.name} needs a password, not switching")
Events.switchNeedsPassword(context, bridge, profile.name ?: profile.gateway, service.profile?.name ?: "", bridge = true)
return
}
DiagLog.i(TAG, "whitelists on, switching to the bridge ${profile.name}")
@@ -290,9 +290,9 @@ object ScenarioEngine
ScenarioAction.VPN_ON, ScenarioAction.SWITCH_SERVER ->
{
val running = service.state != VpnStateService.State.DISABLED
if (profile != null && (!running || service.profile?.getUUID()?.toString() != profile))
{
connect(context, service, profile)
if (profile != null && (!running || service.profile?.getUUID()?.toString() != profile) && !connect(context, service, profile))
{ /* nothing happened: no «scenario did it» event */
return
}
}
ScenarioAction.VPN_OFF -> if (service.state != VpnStateService.State.DISABLED) service.disconnect()
@@ -395,17 +395,26 @@ object ScenarioEngine
}
}
private fun connect(context: Context, service: VpnStateService, uuid: String)
/** Starts [uuid]; false when it could not (no VPN permission, no saved password). */
private fun connect(context: Context, service: VpnStateService, uuid: String): Boolean
{
val needsConsent = runCatching { VpnService.prepare(context) != null }.getOrDefault(true)
if (needsConsent)
{
DiagLog.i(TAG, "VPN permission missing, scenario cannot connect")
return
return false
}
/* a password typed for the running session goes along with it */
val livePassword = service.profile?.takeIf { it.getUUID().toString() == uuid }?.password
if (livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
{
return false
}
val info = Bundle()
info.putString(VpnProfileDataSource.KEY_UUID, uuid)
livePassword?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
service.connect(info, true)
return true
}
private fun defaultProfile(context: Context): String? =
@@ -68,6 +68,7 @@ class InboxActivity : AppCompatActivity()
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) }
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
}
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
@@ -91,6 +91,7 @@ import org.strongswan.android.logic.profiles.ProfileExtra
import org.strongswan.android.logic.profiles.ProfileExtras
import org.strongswan.android.logic.profiles.ProfileSourceType
import org.strongswan.android.logic.profiles.ProfileUpdater
import org.strongswan.android.logic.profiles.needsPassword
import org.strongswan.android.logic.profiles.RESERVE_TIMEOUT_S
import org.strongswan.android.ui.compose.ProfileQr
import org.strongswan.android.ui.compose.ShareProfileScreen
@@ -325,6 +326,8 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
val others = all.filter { it.getUUID().toString() != uuid }
fun nameOf(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.let { it.name ?: it.gateway }
/* a reserve or bridge that signs in with an unsaved password cannot be switched to */
fun lacksPassword(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.needsPassword() ?: false
Group(stringResource(R.string.server_group_server))
{
ValueRow(stringResource(R.string.server_addr), profile.gateway + (profile.port?.let { ":$it" } ?: ""), onClick = ::edit, divider = true)
@@ -338,8 +341,13 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
)
ValueRow(
stringResource(R.string.server_reserve), nameOf(extra.reserveUuid) ?: stringResource(R.string.server_none),
sub = stringResource(R.string.server_reserve_sub, RESERVE_TIMEOUT_S), onClick = { picker = Picker.RESERVE }, divider = true,
sub = stringResource(R.string.server_reserve_sub, RESERVE_TIMEOUT_S), onClick = { picker = Picker.RESERVE },
divider = !lacksPassword(extra.reserveUuid),
)
if (lacksPassword(extra.reserveUuid))
{
PasswordWarning(divider = true)
}
val bridgeName = nameOf(extra.bridgeUuid)
ToggleRow(
stringResource(R.string.server_bridge),
@@ -349,6 +357,10 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
if (on && bridgeName == null) picker = Picker.BRIDGE
else ProfileExtras.update(context, uuid) { it.copy(bridgeEnabled = on) }
}
if (extra.bridgeEnabled && lacksPassword(extra.bridgeUuid))
{
PasswordWarning(divider = false)
}
}
Group(stringResource(R.string.server_group_network))
{
@@ -496,6 +508,25 @@ private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
}
}
/** «No saved password» note under a reserve / bridge row. */
@Composable
private fun PasswordWarning(divider: Boolean)
{
val c = LocalShxColors.current
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 14.dp, bottom = 12.dp),
)
{
Icon(ShxIcons.Alert, contentDescription = null, tint = c.onWarningContainer, modifier = Modifier.size(16.dp))
Text(stringResource(R.string.server_needs_password), style = ShxType.meta.copy(fontWeight = FontWeight.SemiBold, lineHeight = 16.sp), color = c.onWarningContainer)
}
if (divider)
{
Divider()
}
}
/** «443, 53» → [443, 53]; null when something is not a port. */
internal fun parsePorts(text: String): List<Int>?
{
@@ -308,5 +308,6 @@ private fun EventAction.labelRes(): Int = when (this)
EventAction.INSTALL_UPDATE -> R.string.events_action_update
EventAction.SET_APP_DIRECT -> R.string.events_action_direct
EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update
EventAction.OPEN_SERVER -> R.string.events_action_open_server
EventAction.NONE -> R.string.events_action_open
}
@@ -1338,4 +1338,13 @@
<string name="picker_all">Все серверы</string>
<string name="picker_add">Добавить</string>
<string name="scenario_lower">Опустить ниже</string>
<string name="event_restore_pw_title">«%1$s» не восстановлено</string>
<string name="event_restore_pw_text">Подключение ожидалось после перезапуска, но в профиле нет сохранённого пароля. Подключитесь вручную или сохраните пароль в профиле.</string>
<string name="event_switch_pw_title">Не переключились на «%1$s»</string>
<string name="event_switch_pw_reserve">Это резервный сервер для «%1$s», но у него нет сохранённого пароля. Сохраните пароль в профиле, чтобы переключение работало.</string>
<string name="event_switch_pw_bridge">Это мост для «%1$s», но у него нет сохранённого пароля. Сохраните пароль в профиле, чтобы переключение работало.</string>
<string name="events_action_open_server">Открыть сервер</string>
<string name="server_needs_password">Нет сохранённого пароля: переключиться на этот сервер не получится. Сохраните пароль в его профиле.</string>
<string name="event_auto_pw_title">«%1$s» не подключён автоматически</string>
<string name="event_auto_pw_text">Сетевое правило или сценарий хотели включить VPN, но в профиле нет сохранённого пароля. Сохраните пароль в профиле.</string>
</resources>
@@ -1324,4 +1324,13 @@
<string name="picker_all">All servers</string>
<string name="picker_add">Add</string>
<string name="scenario_lower">Move down</string>
<string name="event_restore_pw_title">«%1$s» was not reconnected</string>
<string name="event_restore_pw_text">The connection was expected after a restart, but the profile has no saved password. Connect by hand or save the password in the profile.</string>
<string name="event_switch_pw_title">Could not switch to «%1$s»</string>
<string name="event_switch_pw_reserve">It is the reserve server of «%1$s», but it has no saved password. Save it in the profile so the switch works.</string>
<string name="event_switch_pw_bridge">It is the bridge of «%1$s», but it has no saved password. Save it in the profile so the switch works.</string>
<string name="events_action_open_server">Open server</string>
<string name="server_needs_password">No saved password: switching to it will not work. Save it in its profile.</string>
<string name="event_auto_pw_title">«%1$s» was not connected automatically</string>
<string name="event_auto_pw_text">A network rule or a scenario wanted the VPN on, but the profile has no saved password. Save it in the profile.</string>
</resources>