fix(vpn): improve support for importing WireGuard configs

This commit is contained in:
Michael Aaron Murphy 2024-10-08 17:14:12 +02:00 committed by Michael Murphy
parent 537592a5ac
commit 47edd44c33
5 changed files with 260 additions and 65 deletions

View file

@ -5,7 +5,7 @@ pub mod vpn;
pub mod wifi;
pub mod wired;
use std::{ffi::OsStr, io, process::ExitStatus, sync::Arc};
use std::{ffi::OsStr, process::Stdio, sync::Arc};
use anyhow::Context;
use cosmic::{widget, Apply, Command, Element};
@ -354,29 +354,33 @@ impl Page {
}
}
async fn nm_add_vpn_file<P: AsRef<OsStr>>(type_: &str, path: P) -> io::Result<ExitStatus> {
async fn nm_add_vpn_file<P: AsRef<OsStr>>(type_: &str, path: P) -> Result<(), String> {
tokio::process::Command::new("nmcli")
.args(["connection", "import", "type", type_, "file"])
.arg(path)
.status()
.stderr(Stdio::piped())
.output()
.await
.apply(crate::utils::map_stderr_output)
}
async fn nm_add_wired() -> io::Result<ExitStatus> {
async fn nm_add_wired() -> Result<(), String> {
nm_connection_editor(&["--type=802-3-ethernet", "-c"]).await
}
async fn nm_add_wifi() -> io::Result<ExitStatus> {
async fn nm_add_wifi() -> Result<(), String> {
nm_connection_editor(&["--type=802-11-wireless", "-c"]).await
}
async fn nm_edit_connection(uuid: &str) -> io::Result<ExitStatus> {
async fn nm_edit_connection(uuid: &str) -> Result<(), String> {
nm_connection_editor(&[&["--edit=", uuid].concat()]).await
}
async fn nm_connection_editor(args: &[&str]) -> io::Result<ExitStatus> {
async fn nm_connection_editor(args: &[&str]) -> Result<(), String> {
tokio::process::Command::new(NM_CONNECTION_EDITOR)
.args(args)
.status()
.stderr(Stdio::piped())
.output()
.await
.apply(crate::utils::map_stderr_output)
}

View file

@ -31,6 +31,8 @@ pub enum Message {
Activate(ConnectionId),
/// Add a network connection
AddNetwork,
/// Show a dialog requesting a name for the WireGuard device
AddWireGuardDevice(String, String, String),
/// Cancels an active dialog.
CancelDialog,
/// Connect to a VPN with the given username and password
@ -38,9 +40,9 @@ pub enum Message {
/// Deactivate a connection.
Deactivate(ConnectionId),
/// An error occurred.
Error(String),
Error(ErrorKind, String),
/// Update the list of known connections.
KnownConnections(IndexMap<UUID, VpnConnectionSettings>),
KnownConnections(IndexMap<UUID, ConnectionSettings>),
/// An update from the network manager daemon
NetworkManager(network_manager::Event),
/// Successfully connected to the system dbus.
@ -70,6 +72,39 @@ pub enum Message {
UsernameUpdate(String),
/// Display more options for an access point
ViewMore(Option<ConnectionId>),
/// Create a new wireguard connection
WireGuardConfig,
/// Update the text input for the wireguard device name
WireGuardDeviceInput(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ErrorKind {
Config,
Connect,
ConnectionEditor,
ConnectionSettings,
DbusConnection,
UpdatingState,
WireGuardConfigPath,
WireGuardDevice,
WithPassword(&'static str),
}
impl ErrorKind {
pub fn localized(self) -> String {
match self {
ErrorKind::Config => fl!("vpn-error", "config"),
ErrorKind::Connect => fl!("vpn-error", "connect"),
ErrorKind::ConnectionEditor => fl!("vpn-error", "connection-editor"),
ErrorKind::ConnectionSettings => fl!("vpn-error", "connection-settings"),
ErrorKind::DbusConnection => fl!("dbus-connection-error"),
ErrorKind::UpdatingState => fl!("vpn-error", "updating-state"),
ErrorKind::WireGuardConfigPath => fl!("vpn-error", "wireguard-config-path"),
ErrorKind::WireGuardDevice => fl!("vpn-error", "wireguard-device"),
ErrorKind::WithPassword(field) => fl!("vpn-error", "with-password", field = field),
}
}
}
impl From<Message> for crate::app::Message {
@ -84,6 +119,12 @@ impl From<Message> for crate::pages::Message {
}
}
#[derive(Clone, Debug)]
pub enum ConnectionSettings {
Vpn(VpnConnectionSettings),
Wireguard { id: String },
}
#[derive(Clone, Debug, Default)]
pub struct VpnConnectionSettings {
id: String,
@ -127,6 +168,7 @@ enum PasswordFlag {
#[derive(Clone, Debug, Eq, PartialEq)]
enum VpnDialog {
Error(ErrorKind, String),
Password {
id: String,
uuid: Arc<str>,
@ -135,6 +177,7 @@ enum VpnDialog {
password_hidden: bool,
},
RemoveProfile(ConnectionId),
WireGuardName(String, String, String),
}
#[derive(Debug)]
@ -151,7 +194,8 @@ pub struct Page {
nm_state: Option<NmState>,
dialog: Option<VpnDialog>,
view_more_popup: Option<ConnectionId>,
known_connections: IndexMap<UUID, VpnConnectionSettings>,
known_connections: IndexMap<UUID, ConnectionSettings>,
wireguard_connections: IndexMap<UUID, String>,
/// Withhold device update if the view more popup is shown.
withheld_devices: Option<Vec<network_manager::devices::DeviceInfo>>,
/// Withhold active connections update if the view more popup is shown.
@ -176,6 +220,21 @@ impl page::Page<crate::pages::Message> for Page {
fn dialog(&self) -> Option<Element<crate::pages::Message>> {
self.dialog.as_ref().map(|dialog| match dialog {
VpnDialog::Error(error_kind, message) => {
let reason = widget::text::body(message.as_str()).wrap(Wrap::Word);
let primary_action =
widget::button::standard(fl!("ok")).on_press(Message::CancelDialog);
widget::dialog(fl!("vpn-error"))
.icon(icon::from_name("dialog-error-symbolic").size(64))
.body(error_kind.localized())
.control(reason)
.primary_action(primary_action)
.apply(Element::from)
.map(crate::pages::Message::Vpn)
}
VpnDialog::Password {
username,
password,
@ -216,6 +275,27 @@ impl page::Page<crate::pages::Message> for Page {
.map(crate::pages::Message::Vpn)
}
VpnDialog::WireGuardName(device, ..) => {
let input = widget::text_input("", device.as_str()).on_input(|input| {
Message::WireGuardDeviceInput(input.replace(|c: char| !c.is_alphanumeric(), ""))
});
let primary_action =
widget::button::suggested(fl!("connect")).on_press(Message::WireGuardConfig);
let secondary_action =
widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog);
widget::dialog(fl!("wireguard-dialog"))
.icon(icon::from_name("network-vpn-symbolic").size(64))
.body(fl!("wireguard-dialog", "description"))
.control(input)
.primary_action(primary_action)
.secondary_action(secondary_action)
.apply(Element::from)
.map(crate::pages::Message::Vpn)
}
VpnDialog::RemoveProfile(uuid) => {
let primary_action = widget::button::destructive(fl!("remove"))
.on_press(Message::RemoveProfile(uuid.clone()));
@ -258,7 +338,7 @@ impl page::Page<crate::pages::Message> for Page {
.await
.context("failed to create system dbus connection")
.map_or_else(
|why| Message::Error(why.to_string()),
|why| Message::Error(ErrorKind::DbusConnection, why.to_string()),
|conn| Message::NetworkManagerConnect((conn, sender.clone())),
)
});
@ -357,10 +437,50 @@ impl Page {
Message::AddNetwork => return add_network(),
Message::AddWireGuardDevice(device, filename, path) => {
self.dialog = Some(VpnDialog::WireGuardName(device, filename, path));
}
Message::WireGuardDeviceInput(input) => {
if let Some(VpnDialog::WireGuardName(ref mut device, ..)) = self.dialog {
*device = input
}
}
Message::WireGuardConfig => {
if let Some(VpnDialog::WireGuardName(device, filename, path)) = self.dialog.take() {
return cosmic::command::future(async move {
let new_path = path.replace(&filename, &device);
_ = std::fs::rename(&path, &new_path);
match super::nm_add_vpn_file("wireguard", new_path).await {
Ok(_) => Message::Refresh,
Err(why) => Message::Error(ErrorKind::Config, why.to_string()),
}
});
}
}
Message::Activate(uuid) => {
self.close_popup_and_apply_updates();
if let Some(settings) = self.known_connections.get(&uuid) {
let settings = match settings {
ConnectionSettings::Vpn(ref settings) => settings,
ConnectionSettings::Wireguard { id } => {
let connection_name = id.clone();
return cosmic::command::future(async move {
if let Err(why) = nmcli::connect(&connection_name).await {
return Message::Error(
ErrorKind::Connect,
format!("failed to connect to WireGuard VPN: {why}"),
);
}
Message::Refresh
});
}
};
match settings.password_flag() {
Some(PasswordFlag::NotSaved | PasswordFlag::AgentOwned) => {
self.view_more_popup = None;
@ -377,9 +497,10 @@ impl Page {
let connection_name = settings.id.clone();
return cosmic::command::future(async move {
if let Err(why) = nmcli::connect(&connection_name).await {
return Message::Error(format!(
"failed to connect to VPN: {why}"
));
return Message::Error(
ErrorKind::Connect,
format!("failed to connect to VPN: {why}"),
);
}
Message::Refresh
@ -422,9 +543,11 @@ impl Page {
return cosmic::command::future(async move {
super::nm_edit_connection(uuid.as_ref())
.then(|res| async move {
match res.context("failed to open connection editor") {
match res {
Ok(_) => Message::Refresh,
Err(why) => Message::Error(why.to_string()),
Err(why) => {
Message::Error(ErrorKind::ConnectionEditor, why.to_string())
}
}
})
.await
@ -491,8 +614,9 @@ impl Page {
}
}
Message::Error(why) => {
tracing::error!(why);
Message::Error(error_kind, why) => {
tracing::error!(?error_kind, why);
self.dialog = Some(VpnDialog::Error(error_kind, why))
}
Message::NetworkManagerConnect((conn, output)) => {
@ -511,21 +635,19 @@ impl Page {
) -> Command<Message> {
cosmic::command::future(async move {
if let Err(why) = nmcli::set_username(&connection_name, &username).await {
return Message::Error(format!("failed to set VPN username: {why}"));
return Message::Error(ErrorKind::WithPassword("username"), why.to_string());
}
if let Err(why) = nmcli::set_password_flags_none(&connection_name).await {
return Message::Error(format!(
"failed to call nmcli to set VPN password-flags parameter: {why}"
));
return Message::Error(ErrorKind::WithPassword("password-flags"), why.to_string());
}
if let Err(why) = nmcli::set_password(&connection_name, password.unsecure()).await {
return Message::Error(format!("failed to call nmcli to set VPN password: {why}"));
return Message::Error(ErrorKind::WithPassword("password"), why.to_string());
}
if let Err(why) = nmcli::connect(&connection_name).await {
return Message::Error(format!("failed to connect to VPN: {why}"));
return Message::Error(ErrorKind::Connect, why.to_string());
}
Message::Refresh
@ -631,10 +753,13 @@ fn devices_view() -> Section<crate::pages::Message> {
let known_networks = page.known_connections.iter().fold(
vpn_connections,
|networks, (uuid, connection)| {
let id = match connection {
ConnectionSettings::Vpn(connection) => connection.id.as_str(),
ConnectionSettings::Wireguard { id } => id.as_str(),
};
let is_connected = active_conns.iter().any(|conn| match conn {
ActiveConnectionInfo::Vpn { name, .. } => {
name.as_str() == connection.id.as_str()
}
ActiveConnectionInfo::Vpn { name, .. } => name.as_str() == id,
_ => false,
});
@ -648,8 +773,7 @@ fn devices_view() -> Section<crate::pages::Message> {
)
};
let identifier =
widget::text::body(connection.id.as_str()).wrap(Wrap::Glyph);
let identifier = widget::text::body(id).wrap(Wrap::Glyph);
let connect: Element<'_, Message> = if let Some(msg) = connect_msg {
widget::button::text(connect_txt).on_press(msg).into()
@ -739,7 +863,7 @@ fn update_state(conn: zbus::Connection) -> Command<crate::app::Message> {
cosmic::command::future(async move {
match NetworkManagerState::new(&conn).await {
Ok(state) => Message::UpdateState(state),
Err(why) => Message::Error(why.to_string()),
Err(why) => Message::Error(ErrorKind::UpdatingState, why.to_string()),
}
})
}
@ -751,7 +875,7 @@ fn update_devices(conn: zbus::Connection) -> Command<crate::app::Message> {
match network_manager::devices::list(&conn, filter).await {
Ok(devices) => Message::UpdateDevices(devices),
Err(why) => Message::Error(why.to_string()),
Err(why) => Message::Error(ErrorKind::UpdatingState, why.to_string()),
}
})
}
@ -774,17 +898,37 @@ fn add_network() -> Command<crate::app::Message> {
.then(|result| async move {
match result {
Ok(response) => {
let vpn_type = if response.url().as_str().ends_with(".conf") {
"wireguard"
let response_str = response.url().as_str();
let result = if let Some(device) = response_str.strip_suffix(".conf") {
let Ok(path) = response.url().to_file_path() else {
return Message::Error(
ErrorKind::WireGuardConfigPath,
fl!("vpn-error", "wireguard-config-path-desc"),
);
};
let path = path.to_string_lossy().to_string();
let filename = device.rsplit_once("/").unwrap_or_default().1;
let mut device = filename
.replace(|c: char| !c.is_alphanumeric(), "")
.to_ascii_lowercase();
device.truncate(15);
return Message::AddWireGuardDevice(device, filename.to_owned(), path);
} else {
"openvpn"
super::nm_add_vpn_file("openvpn", response.url().path()).await
};
_ = super::nm_add_vpn_file(vpn_type, response.url().path()).await;
Message::Refresh
match result {
Ok(_) => Message::Refresh,
Err(why) => Message::Error(ErrorKind::Config, why.to_string()),
}
}
Err(why) => {
return Message::Error(why.to_string());
return Message::Error(ErrorKind::Config, why.to_string());
}
}
})
@ -810,12 +954,26 @@ fn connection_settings(conn: zbus::Connection) -> Command<crate::app::Message> {
.filter_map(|conn| async move {
let settings = conn.get_settings().await.ok()?;
let (connection, vpn) = settings.get("connection").zip(settings.get("vpn"))?;
let connection = settings.get("connection")?;
if connection.get("type")?.downcast_ref::<String>().ok()? != "vpn" {
return None;
match connection
.get("type")?
.downcast_ref::<String>()
.ok()?
.as_str()
{
"vpn" => (),
"wireguard" => {
let id = connection.get("id")?.downcast_ref::<String>().ok()?;
let uuid = connection.get("uuid")?.downcast_ref::<String>().ok()?;
return Some((Arc::from(uuid), ConnectionSettings::Wireguard { id }));
}
_ => return None,
}
let vpn = settings.get("vpn")?;
let id = connection.get("id")?.downcast_ref::<String>().ok()?;
let uuid = connection.get("uuid")?.downcast_ref::<String>().ok()?;
@ -858,12 +1016,12 @@ fn connection_settings(conn: zbus::Connection) -> Command<crate::app::Message> {
Some((
Arc::from(uuid),
VpnConnectionSettings {
ConnectionSettings::Vpn(VpnConnectionSettings {
id,
connection_type,
password_flag,
username,
},
}),
))
})
// Reduce the settings list into
@ -877,12 +1035,9 @@ fn connection_settings(conn: zbus::Connection) -> Command<crate::app::Message> {
};
cosmic::command::future(async move {
settings
.await
.context("failed to get connection settings")
.map_or_else(
|why| Message::Error(why.to_string()),
Message::KnownConnections,
)
settings.await.map_or_else(
|why| Message::Error(ErrorKind::ConnectionSettings, why.to_string()),
Message::KnownConnections,
)
})
}

View file

@ -1,18 +1,19 @@
// Copyright 2024 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use as_result::IntoResult;
use std::io;
use cosmic::Apply;
use std::process::Stdio;
pub async fn set_username(connection_name: &str, username: &str) -> io::Result<()> {
pub async fn set_username(connection_name: &str, username: &str) -> Result<(), String> {
tokio::process::Command::new("nmcli")
.args(&["con", "mod", connection_name, "vpn.user-name", username])
.status()
.stderr(Stdio::piped())
.output()
.await
.and_then(IntoResult::into_result)
.apply(crate::utils::map_stderr_output)
}
pub async fn set_password_flags_none(connection_name: &str) -> io::Result<()> {
pub async fn set_password_flags_none(connection_name: &str) -> Result<(), String> {
tokio::process::Command::new("nmcli")
.args(&[
"con",
@ -21,12 +22,13 @@ pub async fn set_password_flags_none(connection_name: &str) -> io::Result<()> {
"+vpn.data",
"password-flags=0",
])
.status()
.stderr(Stdio::piped())
.output()
.await
.and_then(IntoResult::into_result)
.apply(crate::utils::map_stderr_output)
}
pub async fn set_password(connection_name: &str, password: &str) -> io::Result<()> {
pub async fn set_password(connection_name: &str, password: &str) -> Result<(), String> {
tokio::process::Command::new("nmcli")
.args(&[
"con",
@ -35,15 +37,17 @@ pub async fn set_password(connection_name: &str, password: &str) -> io::Result<(
"vpn.secrets",
&format!("password={password}"),
])
.status()
.stderr(Stdio::piped())
.output()
.await
.and_then(IntoResult::into_result)
.apply(crate::utils::map_stderr_output)
}
pub async fn connect(connection_name: &str) -> io::Result<()> {
pub async fn connect(connection_name: &str) -> Result<(), String> {
tokio::process::Command::new("nmcli")
.args(&["con", "up", &connection_name])
.status()
.stderr(Stdio::piped())
.output()
.await
.and_then(IntoResult::into_result)
.apply(crate::utils::map_stderr_output)
}

View file

@ -1,4 +1,4 @@
use std::future::Future;
use std::{future::Future, io, process};
use futures::{future::select, StreamExt};
@ -47,6 +47,17 @@ pub fn forward_event_loop<M: 'static + Send, T: Future<Output = ()> + Send + 'st
cancel_tx
}
/// On process failure, return stderr as `String`.
pub fn map_stderr_output(result: io::Result<process::Output>) -> Result<(), String> {
result.map_err(|why| why.to_string()).and_then(|output| {
if !output.status.success() {
Err(String::from_utf8(output.stderr).unwrap_or_default())
} else {
Ok(())
}
})
}
/// Creates a slab with predefined items
#[macro_export]
macro_rules! slab {

View file

@ -1,5 +1,7 @@
app = COSMIC Settings
dbus-connection-error = Failed to connect to DBus
ok = OK
unknown = Unknown
number = { $number }
@ -62,9 +64,25 @@ remove-connection-dialog = Remove Connection Profile?
vpn = VPN
.connections = VPN Connections
.error = Failed to add VPN config
.remove = Remove connection profile
.select-file = Select a VPN configuration file
vpn-error = VPN Error
.config = Failed to add VPN config
.connect = Failed to connect to VPN
.connection-editor = Connection editor failed
.connection-settings = Failed to get settings for active connections
.updating-state = Failed to update network manager state
.wireguard-config-path = Invalid file path for WireGuard config
.wireguard-config-path-desc = Chosen file must be on a local file system.
.wireguard-device = Failed to create WireGuard device
.with-password = Failed to set VPN { $field ->
*[username] username
[password] password
[password-flags] password-flags
} with nmcli
wired = Wired
.adapter = Wired adapter { $id }
.connections = Wired Connections
@ -75,6 +93,9 @@ wifi = Wi-Fi
.adapter = Wi-Fi adapter { $id }
.forget = Forget this network
wireguard-dialog = Add WireGuard device
.description = Choose a device name for the WireGuard config.
## Networking: Online Accounts
online-accounts = Online Accounts