From 3dfd11b329cc5661298ef59fcd6fd3b5153e97e1 Mon Sep 17 00:00:00 2001 From: David Lönnhager Date: Fri, 24 Apr 2026 14:50:09 +0200 Subject: TMP: add import feature --- .../lib/feature/impl/PersonalVpnScreen.kt | 38 +++++++++++++++++++--- .../lib/feature/impl/PersonalVpnViewModel.kt | 24 +++++++++++++- .../mullvadvpn/lib/grpc/ManagementService.kt | 24 ++++++++++++++ mullvad-cli/src/cmds/personal_vpn.rs | 6 +--- mullvad-daemon/src/management_interface.rs | 26 +++++++++++++++ .../proto/management_interface.proto | 2 ++ mullvad-management-interface/src/client.rs | 7 ++++ 7 files changed, 116 insertions(+), 11 deletions(-) diff --git a/android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnScreen.kt b/android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnScreen.kt index 16f5488059..56c368003c 100644 --- a/android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnScreen.kt +++ b/android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnScreen.kt @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.lib.feature.impl +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.animateContentSize @@ -51,6 +53,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -58,6 +61,7 @@ import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -124,6 +128,7 @@ fun SharedTransitionScope.PersonalVpn( onClearAllowedIpErrors = vm::onClearAllowedIpErrors, vm::save, clearConfig = vm::clearConfig, + importConfig = vm::import, modifier = Modifier.sharedBounds( rememberSharedContentState(key = FeatureIndicator.PERSONAL_VPN), @@ -151,6 +156,7 @@ private fun PreviewPersonalVpnScreen() { onTogglePersonalVpn = {}, saveConfig = {}, clearConfig = {}, + importConfig = {}, ) } } @@ -166,29 +172,33 @@ fun PersonalVpnScreen( onClearAllowedIpErrors: () -> Unit = {}, saveConfig: (PersonalVpnFormData) -> Unit, clearConfig: () -> Unit, + importConfig: (String) -> Unit, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { if (state !is Lc.Content) return val initialFormData = state.value.initialFormData - val privateKeyTextFieldState = rememberTextFieldState(initialFormData.privateKey) + val privateKeyTextFieldState = + key(initialFormData) { rememberTextFieldState(initialFormData.privateKey) } LaunchedEffect(privateKeyTextFieldState.text) { val error = state.value.privateKeyDataError ?: return@LaunchedEffect onClearError(error) } - val addressTextFieldState = rememberTextFieldState(initialFormData.tunnelIp) + val addressTextFieldState = + key(initialFormData) { rememberTextFieldState(initialFormData.tunnelIp) } LaunchedEffect(addressTextFieldState.text) { val error = state.value.tunnelIpDataError ?: return@LaunchedEffect onClearError(error) } - val publicKeyTextFieldState = rememberTextFieldState(initialFormData.publicKey) + val publicKeyTextFieldState = + key(initialFormData) { rememberTextFieldState(initialFormData.publicKey) } LaunchedEffect(publicKeyTextFieldState.text) { val error = state.value.publicKeyDataError ?: return@LaunchedEffect onClearError(error) } - val allowedIpTextFieldStates = remember { + val allowedIpTextFieldStates = remember(initialFormData) { initialFormData.allowedIPs.map { TextFieldState(it) }.toMutableStateList() } @@ -200,7 +210,8 @@ fun PersonalVpnScreen( } } - val endpointTextFieldState = rememberTextFieldState(initialFormData.endpoint) + val endpointTextFieldState = + key(initialFormData) { rememberTextFieldState(initialFormData.endpoint) } LaunchedEffect(endpointTextFieldState.text) { val error = state.value.endpointDataError ?: return@LaunchedEffect onClearError(error) @@ -214,6 +225,18 @@ fun PersonalVpnScreen( currentAllowedIps != initialFormData.allowedIPs || endpointTextFieldState.text != initialFormData.endpoint + val context = LocalContext.current + val openFileLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + val body = + context.contentResolver.openInputStream(uri)?.use { stream -> + stream.reader(Charsets.UTF_8).readText() + } + if (body != null) importConfig(body) + } + } + ScaffoldWithSmallTopBar( appBarTitle = stringResource(id = R.string.personal_vpn), modifier = modifier.imePadding(), @@ -248,6 +271,11 @@ fun PersonalVpnScreen( }, ) + PrimaryButton( + text = "Import from file", + onClick = { openFileLauncher.launch(arrayOf("*/*")) }, + ) + NegativeButton( text = "Delete", isEnabled = state.value.clearEnabled, diff --git a/android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnViewModel.kt b/android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnViewModel.kt index 9e89f7d497..392b74517d 100644 --- a/android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnViewModel.kt +++ b/android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart @@ -37,7 +38,7 @@ import net.mullvad.mullvadvpn.lib.model.WireguardKey import net.mullvad.mullvadvpn.lib.repository.SettingsRepository class PersonalVpnViewModel( - settingsRepository: SettingsRepository, + private val settingsRepository: SettingsRepository, val managementService: ManagementService, ) : ViewModel() { private val _uiSideEffect = Channel() @@ -103,6 +104,27 @@ class PersonalVpnViewModel( } } + fun import(body: String) { + viewModelScope.launch { + managementService + .importPersonalVpnConfig(body) + .fold( + { + _uiSideEffect.send( + PersonalVpnSideEffect.FailedToSave(it.toString()) + ) + }, + { + val settings = + settingsRepository.settingsUpdates.filterNotNull().first() + _formData.value = + PersonalVpnFormData.from(settings.personalVpnConfig) + _uiSideEffect.send(PersonalVpnSideEffect.ConfigurationSaved) + }, + ) + } + } + fun save(formData: PersonalVpnFormData) = viewModelScope.launch { parseFormData(formData) .fold( diff --git a/android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt b/android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt index 7daffa812c..d6fe7adf44 100644 --- a/android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt +++ b/android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt @@ -2,7 +2,9 @@ package net.mullvad.mullvadvpn.lib.grpc import android.net.LocalSocketAddress import arrow.core.Either +import arrow.core.flatMap import arrow.core.flatten +import arrow.core.left import arrow.core.raise.either import arrow.core.raise.ensure import arrow.core.right @@ -1014,6 +1016,28 @@ class ManagementService( } .flatten() + /** + * Parse and store a wg-quick formatted personal VPN config on the daemon side. + * + * The daemon parses the `wg-quick` body, resolves any DNS hostname in `Endpoint`, + * and persists the resulting config. Returns the daemon's error string if the + * config is invalid or DNS resolution fails. + */ + suspend fun importPersonalVpnConfig(body: String): Either = + Either.catch { grpc.importPersonalVpnConfig(StringValue.of(body)) } + .mapLeft { + Logger.d("importPersonalVpnConfig failed: $it") + SetPersonalVpnConfigError.Unknown + } + .flatMap { response -> + if (response.error.isEmpty()) { + Unit.right() + } else { + Logger.d("importPersonalVpnConfig daemon error: ${response.error}") + SetPersonalVpnConfigError.Unknown.left() + } + } + fun personalVpnStats(): Flow = grpc.getPersonalVpnStats(Empty.getDefaultInstance()).map { it.toDomain() } diff --git a/mullvad-cli/src/cmds/personal_vpn.rs b/mullvad-cli/src/cmds/personal_vpn.rs index f131426f83..c0d8d3e5f9 100644 --- a/mullvad-cli/src/cmds/personal_vpn.rs +++ b/mullvad-cli/src/cmds/personal_vpn.rs @@ -8,7 +8,6 @@ use std::{ fs, io::{self, Read}, net::IpAddr, - str::FromStr, }; use talpid_types::net::wireguard; use talpid_types::net::wireguard::{ @@ -160,11 +159,8 @@ impl PersonalVpn { }) .await??; - let config = UnresolvedPersonalVpnConfig::from_str(&config_str) - .context("Failed to parse WireGuard config")?; - let mut rpc = MullvadProxyClient::new().await?; - let error = rpc.set_personal_vpn_config(Some(config)).await?; + let error = rpc.import_personal_vpn_config(config_str).await?; if !error.is_empty() { anyhow::bail!("Daemon returned error: {error}"); } diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs index da0076c244..020207dafb 100644 --- a/mullvad-daemon/src/management_interface.rs +++ b/mullvad-daemon/src/management_interface.rs @@ -1363,6 +1363,21 @@ impl ManagementService for ManagementServiceImpl { Ok(Response::new(())) } + #[cfg(feature = "personal-vpn")] + async fn import_personal_vpn_config( + &self, + request: Request, + ) -> ServiceResult { + log::debug!("import_personal_vpn_config"); + let body = request.into_inner(); + let config = ::from_str(&body) + .map_err(|err| Status::invalid_argument(format!("failed to parse config: {err}")))?; + let (tx, rx) = oneshot::channel(); + self.send_command_to_daemon(DaemonCommand::SetPersonalVpnConfig(tx, Some(config)))?; + let error = self.wait_for_result(rx).await?; + Ok(Response::new(types::PersonalVpnConfigError { error })) + } + #[cfg(feature = "personal-vpn")] async fn get_personal_vpn_stats( &self, @@ -1400,6 +1415,17 @@ impl ManagementService for ManagementServiceImpl { Ok(Response::new(())) } + #[cfg(not(feature = "personal-vpn"))] + async fn import_personal_vpn_config( + &self, + _request: Request, + ) -> ServiceResult { + log::debug!("import_personal_vpn_config"); + Ok(Response::new(types::PersonalVpnConfigError { + error: "".to_string(), + })) + } + #[cfg(not(feature = "personal-vpn"))] async fn get_personal_vpn_stats( &self, diff --git a/mullvad-management-interface/proto/management_interface.proto b/mullvad-management-interface/proto/management_interface.proto index dfefd54f75..27b8d24864 100644 --- a/mullvad-management-interface/proto/management_interface.proto +++ b/mullvad-management-interface/proto/management_interface.proto @@ -157,6 +157,8 @@ service ManagementService { // Personal VPN rpc SetPersonalVpnConfig(PersonalVpnConfig) returns (PersonalVpnConfigError) {} rpc SetPersonalVpnConfigStatus(google.protobuf.BoolValue) returns (google.protobuf.Empty) {} + // Parse a wg-quick config file and store it as the personal VPN config. + rpc ImportPersonalVpnConfig(google.protobuf.StringValue) returns (PersonalVpnConfigError) {} // Always exists rpc GetPersonalVpnStats(google.protobuf.Empty) returns (stream PersonalVpnStats) {} } diff --git a/mullvad-management-interface/src/client.rs b/mullvad-management-interface/src/client.rs index d1594f1e71..bddc94d0be 100644 --- a/mullvad-management-interface/src/client.rs +++ b/mullvad-management-interface/src/client.rs @@ -694,6 +694,13 @@ impl MullvadProxyClient { self.0.set_personal_vpn_config_status(enabled).await?; Ok(()) } + + /// Import a personal VPN configuration from the contents of a wg-quick file. + #[cfg(feature = "personal-vpn")] + pub async fn import_personal_vpn_config(&mut self, body: String) -> Result { + let response = self.0.import_personal_vpn_config(body).await?; + Ok(response.into_inner().error) + } } #[cfg(not(target_os = "android"))] -- cgit v1.3-3-g829e