summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnScreen.kt38
-rw-r--r--android/lib/feature/personalvpn/impl/src/main/java/net/mullvad/mullvadvpn/lib/feature/impl/PersonalVpnViewModel.kt24
-rw-r--r--android/lib/grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/grpc/ManagementService.kt24
-rw-r--r--mullvad-cli/src/cmds/personal_vpn.rs6
-rw-r--r--mullvad-daemon/src/management_interface.rs26
-rw-r--r--mullvad-management-interface/proto/management_interface.proto2
-rw-r--r--mullvad-management-interface/src/client.rs7
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<PersonalVpnSideEffect>()
@@ -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<SetPersonalVpnConfigError, Unit> =
+ 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<TunnelStats> =
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
@@ -1364,6 +1364,21 @@ impl ManagementService for ManagementServiceImpl {
}
#[cfg(feature = "personal-vpn")]
+ async fn import_personal_vpn_config(
+ &self,
+ request: Request<String>,
+ ) -> ServiceResult<types::PersonalVpnConfigError> {
+ log::debug!("import_personal_vpn_config");
+ let body = request.into_inner();
+ let config = <talpid_types::net::wireguard::UnresolvedPersonalVpnConfig as std::str::FromStr>::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,
_: Request<()>,
@@ -1401,6 +1416,17 @@ impl ManagementService for ManagementServiceImpl {
}
#[cfg(not(feature = "personal-vpn"))]
+ async fn import_personal_vpn_config(
+ &self,
+ _request: Request<String>,
+ ) -> ServiceResult<types::PersonalVpnConfigError> {
+ 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,
_: Request<()>,
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<String> {
+ let response = self.0.import_personal_vpn_config(body).await?;
+ Ok(response.into_inner().error)
+ }
}
#[cfg(not(target_os = "android"))]