diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt index fae4c197d..9bf489faf 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/PauseManager.kt @@ -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 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 6e276728d..b55a30621 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 @@ -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; diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/WatchdogReceiver.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/WatchdogReceiver.java index ba8fa1d5f..238ec90f1 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/WatchdogReceiver.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/WatchdogReceiver.java @@ -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. diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt index 4cdb6df31..961c481e6 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/autoconnect/AutoConnectManager.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt index 254e14629..2db3b0e61 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt @@ -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( diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt index b323fc17e..e61cf83cb 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/Events.kt @@ -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, + ) + } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileExtras.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileExtras.kt index 5fab80d02..6ddc46be4 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileExtras.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/profiles/ProfileExtras.kt @@ -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 +} 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 index e9d072726..767db2033 100644 --- 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 @@ -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}") diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt index 955b6cd05..684396777 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/scenarios/Scenarios.kt @@ -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? = diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt index 9bcee5f20..54dddc7e9 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt index c9fbfdec5..42d123828 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ServerActivity.kt @@ -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? { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt index c5b16bf4f..7275e8e37 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt @@ -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 } 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 92eaf311c..2cfdfe1f5 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 @@ -1338,4 +1338,13 @@ Все серверы Добавить Опустить ниже + «%1$s» не восстановлено + Подключение ожидалось после перезапуска, но в профиле нет сохранённого пароля. Подключитесь вручную или сохраните пароль в профиле. + Не переключились на «%1$s» + Это резервный сервер для «%1$s», но у него нет сохранённого пароля. Сохраните пароль в профиле, чтобы переключение работало. + Это мост для «%1$s», но у него нет сохранённого пароля. Сохраните пароль в профиле, чтобы переключение работало. + Открыть сервер + Нет сохранённого пароля: переключиться на этот сервер не получится. Сохраните пароль в его профиле. + «%1$s» не подключён автоматически + Сетевое правило или сценарий хотели включить VPN, но в профиле нет сохранённого пароля. Сохраните пароль в профиле. 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 48cbd00eb..d8e8155f9 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1324,4 +1324,13 @@ All servers Add Move down + «%1$s» was not reconnected + The connection was expected after a restart, but the profile has no saved password. Connect by hand or save the password in the profile. + Could not switch to «%1$s» + It is the reserve server of «%1$s», but it has no saved password. Save it in the profile so the switch works. + It is the bridge of «%1$s», but it has no saved password. Save it in the profile so the switch works. + Open server + No saved password: switching to it will not work. Save it in its profile. + «%1$s» was not connected automatically + A network rule or a scenario wanted the VPN on, but the profile has no saved password. Save it in the profile.