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:
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
|
||||
|
||||
+7
@@ -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;
|
||||
|
||||
+33
@@ -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.
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+32
@@ -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
|
||||
}
|
||||
+8
-3
@@ -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}")
|
||||
|
||||
+14
-5
@@ -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)
|
||||
|
||||
+32
-1
@@ -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>?
|
||||
{
|
||||
|
||||
+1
@@ -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>
|
||||
Reference in new issue
Block a user