1297 lines
52 KiB
Rust
1297 lines
52 KiB
Rust
// Copyright 2024 System76 <info@system76.com>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
pub mod nmcli;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, LazyLock};
|
|
|
|
use anyhow::Context;
|
|
use cosmic::dialog::file_chooser::FileFilter;
|
|
use cosmic::task;
|
|
use cosmic::{
|
|
Apply, Element, Task,
|
|
iced::core::text::Wrapping,
|
|
iced::{Alignment, Length},
|
|
widget::{self, icon, space::horizontal as horizontal_space, text_input::focus},
|
|
};
|
|
use cosmic_settings_network_manager_subscription::nm_secret_agent::{self, PasswordFlag};
|
|
use cosmic_settings_network_manager_subscription::{
|
|
self as network_manager, NetworkManagerState, UUID, current_networks::ActiveConnectionInfo,
|
|
};
|
|
use cosmic_settings_page::{self as page, Section, section};
|
|
use futures::{FutureExt, SinkExt, StreamExt};
|
|
use indexmap::IndexMap;
|
|
use secure_string::SecureString;
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::pages::networking::SecretSender;
|
|
|
|
pub static SECURE_INPUT_VPN: LazyLock<widget::Id> = LazyLock::new(widget::Id::unique);
|
|
|
|
pub type ConnectionId = Arc<str>;
|
|
pub type InterfaceId = String;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum Message {
|
|
/// Activate a connection
|
|
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
|
|
ConnectWithPassword,
|
|
/// Connect to a VPN with the given username and password
|
|
RetryWithPassword,
|
|
/// Deactivate a connection.
|
|
Deactivate(ConnectionId),
|
|
/// An error occurred.
|
|
Error(ErrorKind, String),
|
|
/// Focus the secure input
|
|
FocusSecureInput,
|
|
/// VPN connection error.
|
|
VpnDialogError(VpnDialog),
|
|
/// Update the list of known connections.
|
|
KnownConnections(IndexMap<UUID, ConnectionSettings>),
|
|
/// An update from the network manager daemon
|
|
NetworkManager(network_manager::Event),
|
|
/// An update from the secret agent
|
|
SecretAgent(network_manager::nm_secret_agent::Event),
|
|
/// Successfully connected to the system dbus.
|
|
NetworkManagerConnect(zbus::Connection),
|
|
/// Updates the password text input
|
|
PasswordUpdate(SecureString),
|
|
/// Refresh devices and their connection profiles
|
|
Refresh,
|
|
/// Create a dialog to ask for confirmation of removal.
|
|
RemoveProfileRequest(ConnectionId),
|
|
/// Remove a connection profile
|
|
RemoveProfile(ConnectionId),
|
|
/// Opens settings page for the access point.
|
|
Settings(ConnectionId),
|
|
/// Toggles visibility of password input.
|
|
TogglePasswordVisibility,
|
|
/// Update NetworkManagerState
|
|
UpdateState(NetworkManagerState),
|
|
/// Update the devices lists
|
|
UpdateDevices(Vec<network_manager::devices::DeviceInfo>),
|
|
/// Updates the username text input
|
|
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 {
|
|
fn from(message: Message) -> Self {
|
|
crate::pages::Message::Vpn(message).into()
|
|
}
|
|
}
|
|
|
|
impl From<Message> for crate::pages::Message {
|
|
fn from(message: Message) -> Self {
|
|
crate::pages::Message::Vpn(message)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum ConnectionSettings {
|
|
Vpn(VpnConnectionSettings),
|
|
Wireguard { id: String },
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct VpnConnectionSettings {
|
|
id: String,
|
|
username: Option<String>,
|
|
connection_type: Option<ConnectionType>,
|
|
password_flag: Option<PasswordFlag>,
|
|
}
|
|
|
|
impl VpnConnectionSettings {
|
|
fn password_flag(&self) -> Option<PasswordFlag> {
|
|
self.connection_type
|
|
.as_ref()
|
|
.is_some_and(|ct| match ct {
|
|
ConnectionType::Password => true,
|
|
})
|
|
.then_some(self.password_flag)
|
|
.flatten()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
enum ConnectionType {
|
|
Password,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum VpnDialog {
|
|
Error(ErrorKind, String),
|
|
Password {
|
|
id: String,
|
|
uuid: Arc<str>,
|
|
username: Option<String>,
|
|
password: SecureString,
|
|
description: Option<String>,
|
|
password_hidden: bool,
|
|
tx: SecretSender,
|
|
error: Option<(ErrorKind, String)>,
|
|
},
|
|
RemoveProfile(ConnectionId),
|
|
WireGuardName(String, String, String),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct NmState {
|
|
conn: zbus::Connection,
|
|
sender: futures::channel::mpsc::UnboundedSender<network_manager::Request>,
|
|
active_conns: Vec<ActiveConnectionInfo>,
|
|
devices: Vec<network_manager::devices::DeviceInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct Page {
|
|
entity: page::Entity,
|
|
nm_task: Option<tokio::sync::oneshot::Sender<()>>,
|
|
secret_tx: Option<tokio::sync::mpsc::Sender<nm_secret_agent::Request>>,
|
|
nm_state: Option<NmState>,
|
|
dialog: Option<VpnDialog>,
|
|
view_more_popup: Option<ConnectionId>,
|
|
known_connections: IndexMap<UUID, ConnectionSettings>,
|
|
/// 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.
|
|
withheld_active_conns: Option<Vec<ActiveConnectionInfo>>,
|
|
}
|
|
|
|
impl page::AutoBind<crate::pages::Message> for Page {}
|
|
|
|
impl page::Page<crate::pages::Message> for Page {
|
|
fn set_id(&mut self, entity: page::Entity) {
|
|
self.entity = entity;
|
|
}
|
|
|
|
fn info(&self) -> cosmic_settings_page::Info {
|
|
page::Info::new("vpn", "preferences-vpn-symbolic")
|
|
.title(fl!("vpn"))
|
|
.description(fl!("xdg-entry-vpn-comment"))
|
|
}
|
|
|
|
fn content(
|
|
&self,
|
|
sections: &mut slotmap::SlotMap<section::Entity, Section<crate::pages::Message>>,
|
|
) -> Option<page::Content> {
|
|
Some(vec![sections.insert(devices_view())])
|
|
}
|
|
|
|
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()).wrapping(Wrapping::Word);
|
|
|
|
let primary_action =
|
|
widget::button::standard(fl!("ok")).on_press(Message::CancelDialog);
|
|
|
|
widget::dialog()
|
|
.title(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,
|
|
password_hidden,
|
|
error,
|
|
description,
|
|
..
|
|
} => {
|
|
let username = username.as_ref().map(|username| {
|
|
widget::text_input(fl!("username"), username.as_str())
|
|
.on_input(Message::UsernameUpdate)
|
|
});
|
|
|
|
let password = widget::text_input::secure_input(
|
|
fl!("password"),
|
|
password.unsecure(),
|
|
Some(Message::TogglePasswordVisibility),
|
|
*password_hidden,
|
|
)
|
|
.id(SECURE_INPUT_VPN.clone())
|
|
.on_input(|input| Message::PasswordUpdate(SecureString::from(input)))
|
|
.on_submit(|_| {
|
|
if error.is_some() {
|
|
Message::RetryWithPassword
|
|
} else {
|
|
Message::ConnectWithPassword
|
|
}
|
|
});
|
|
let (err_kind, error_text) = if let Some(err) = error.as_ref() {
|
|
(
|
|
Some(err.0),
|
|
Some(widget::text::body(err.1.as_str()).wrapping(Wrapping::Word)),
|
|
)
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let controls = widget::column::with_capacity(2)
|
|
.spacing(12)
|
|
.push_maybe(username)
|
|
.push(password)
|
|
.push_maybe(error_text)
|
|
.apply(Element::from);
|
|
|
|
let primary_action =
|
|
widget::button::suggested(fl!("connect")).on_press(if error.is_some() {
|
|
Message::RetryWithPassword
|
|
} else {
|
|
Message::ConnectWithPassword
|
|
});
|
|
|
|
let secondary_action =
|
|
widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog);
|
|
|
|
widget::dialog()
|
|
.title(if let Some(error_kind) = err_kind {
|
|
error_kind.localized()
|
|
} else {
|
|
fl!("auth-dialog")
|
|
})
|
|
.icon(icon::from_name("network-vpn-symbolic").size(64))
|
|
.body(if let Some(description) = description.as_ref() {
|
|
description.clone()
|
|
} else {
|
|
fl!("auth-dialog", "vpn-description")
|
|
})
|
|
.control(controls)
|
|
.primary_action(primary_action)
|
|
.secondary_action(secondary_action)
|
|
.apply(Element::from)
|
|
.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()
|
|
.title(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()));
|
|
|
|
let secondary_action =
|
|
widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog);
|
|
|
|
widget::dialog()
|
|
.title(fl!("remove-connection-dialog"))
|
|
.icon(icon::from_name("dialog-information").size(64))
|
|
.body(fl!("remove-connection-dialog", "vpn-description"))
|
|
.primary_action(primary_action)
|
|
.secondary_action(secondary_action)
|
|
.apply(Element::from)
|
|
.map(crate::pages::Message::Vpn)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn header_view(&self) -> Option<Element<'_, crate::pages::Message>> {
|
|
Some(
|
|
widget::button::standard(fl!("add-network"))
|
|
.trailing_icon(icon::from_name("window-pop-out-symbolic"))
|
|
.on_press(Message::AddNetwork)
|
|
.apply(widget::container)
|
|
.width(Length::Fill)
|
|
.align_x(Alignment::End)
|
|
.apply(Element::from)
|
|
.map(crate::pages::Message::Vpn),
|
|
)
|
|
}
|
|
|
|
fn on_enter(&mut self) -> cosmic::Task<crate::pages::Message> {
|
|
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
|
self.secret_tx = Some(tx);
|
|
if self.nm_task.is_none() {
|
|
return cosmic::Task::batch([cosmic::task::future(async move {
|
|
zbus::Connection::system()
|
|
.await
|
|
.context("failed to create system dbus connection")
|
|
.map_or_else(
|
|
|why| Message::Error(ErrorKind::DbusConnection, why.to_string()),
|
|
Message::NetworkManagerConnect,
|
|
)
|
|
}),
|
|
cosmic::Task::stream(
|
|
cosmic_settings_network_manager_subscription::nm_secret_agent::secret_agent_stream("com.system76.CosmicSettings.VPN.NetworkManager.SecretAgent", rx),
|
|
)
|
|
.map(|m| crate::pages::Message::Vpn(Message::SecretAgent(m))),
|
|
]);
|
|
}
|
|
|
|
cosmic::Task::none()
|
|
}
|
|
|
|
fn on_leave(&mut self) -> Task<crate::pages::Message> {
|
|
self.view_more_popup = None;
|
|
self.nm_state = None;
|
|
self.withheld_active_conns = None;
|
|
self.withheld_devices = None;
|
|
self.dialog = None;
|
|
|
|
if let Some(cancel) = self.nm_task.take() {
|
|
_ = cancel.send(());
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
}
|
|
|
|
impl Page {
|
|
pub fn update(&mut self, message: Message) -> Task<crate::app::Message> {
|
|
let span = tracing::span!(tracing::Level::INFO, "vpn::update");
|
|
let _span = span.enter();
|
|
|
|
match message {
|
|
Message::NetworkManager(network_manager::Event::RequestResponse {
|
|
req,
|
|
state,
|
|
success,
|
|
}) => {
|
|
if !success {
|
|
tracing::error!(request = ?req, "network-manager request failed");
|
|
}
|
|
|
|
if let Some(NmState { ref conn, .. }) = self.nm_state {
|
|
let conn = conn.clone();
|
|
self.update_active_conns(state);
|
|
return cosmic::Task::batch(vec![
|
|
connection_settings(conn.clone()),
|
|
update_devices(conn),
|
|
]);
|
|
}
|
|
}
|
|
Message::KnownConnections(connections) => {
|
|
self.known_connections = connections;
|
|
}
|
|
Message::UpdateDevices(devices) => {
|
|
self.update_devices(devices);
|
|
}
|
|
Message::UpdateState(state) => {
|
|
self.update_active_conns(state);
|
|
}
|
|
Message::NetworkManager(
|
|
network_manager::Event::ActiveConns | network_manager::Event::Devices,
|
|
) => {
|
|
if let Some(NmState { ref conn, .. }) = self.nm_state {
|
|
return cosmic::Task::batch(vec![
|
|
update_state(conn.clone()),
|
|
update_devices(conn.clone()),
|
|
connection_settings(conn.clone()),
|
|
]);
|
|
}
|
|
}
|
|
Message::NetworkManager(network_manager::Event::Init {
|
|
conn,
|
|
sender,
|
|
state,
|
|
}) => {
|
|
self.nm_state = Some(NmState {
|
|
conn: conn.clone(),
|
|
sender,
|
|
devices: Vec::new(),
|
|
active_conns: state
|
|
.active_conns
|
|
.into_iter()
|
|
.filter(|info| matches!(info, ActiveConnectionInfo::Vpn { .. }))
|
|
.collect(),
|
|
});
|
|
|
|
return cosmic::Task::batch(vec![
|
|
connection_settings(conn.clone()),
|
|
update_devices(conn),
|
|
]);
|
|
}
|
|
Message::NetworkManager(_event) => (),
|
|
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::task::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(settings) => settings,
|
|
ConnectionSettings::Wireguard { id } => {
|
|
let connection_name = id.clone();
|
|
return cosmic::task::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;
|
|
self.dialog = Some(VpnDialog::Password {
|
|
id: settings.id.clone(),
|
|
uuid: uuid.clone(),
|
|
username: settings.username.clone(),
|
|
password: SecureString::from(""),
|
|
description: None,
|
|
password_hidden: true,
|
|
error: None,
|
|
tx: Arc::new(Mutex::new(None)),
|
|
});
|
|
return task::message(Message::FocusSecureInput);
|
|
}
|
|
_ => {
|
|
let connection_name = settings.id.clone();
|
|
let username = settings.username.clone();
|
|
return cosmic::task::future(async move {
|
|
if let Err(why) = nmcli::connect(&connection_name).await {
|
|
return Message::VpnDialogError(VpnDialog::Password {
|
|
error: Some((
|
|
ErrorKind::Connect,
|
|
format!("failed to connect to VPN: {why}"),
|
|
)),
|
|
id: connection_name.clone(),
|
|
uuid,
|
|
username: username.clone(),
|
|
description: None,
|
|
password: SecureString::from(""),
|
|
password_hidden: true,
|
|
// TODO grab from the current dialog
|
|
tx: Arc::new(Mutex::new(None)),
|
|
});
|
|
}
|
|
|
|
Message::Refresh
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Message::Deactivate(uuid) => {
|
|
self.close_popup_and_apply_updates();
|
|
if let Some(NmState { ref sender, .. }) = self.nm_state {
|
|
_ = sender.unbounded_send(network_manager::Request::Deactivate(uuid));
|
|
}
|
|
}
|
|
Message::RemoveProfileRequest(uuid) => {
|
|
self.view_more_popup = None;
|
|
self.dialog = Some(VpnDialog::RemoveProfile(uuid));
|
|
}
|
|
Message::RemoveProfile(uuid) => {
|
|
self.dialog = None;
|
|
self.close_popup_and_apply_updates();
|
|
if let Some(NmState { ref sender, .. }) = self.nm_state {
|
|
_ = sender.unbounded_send(network_manager::Request::Remove(uuid));
|
|
}
|
|
}
|
|
Message::ViewMore(uuid) => {
|
|
self.view_more_popup = uuid;
|
|
if self.view_more_popup.is_none() {
|
|
self.close_popup_and_apply_updates();
|
|
}
|
|
}
|
|
Message::Settings(uuid) => {
|
|
self.close_popup_and_apply_updates();
|
|
|
|
return cosmic::task::future(async move {
|
|
super::nm_edit_connection(uuid.as_ref())
|
|
.then(|res| async move {
|
|
match res {
|
|
Ok(_) => Message::Refresh,
|
|
Err(why) => {
|
|
Message::Error(ErrorKind::ConnectionEditor, why.to_string())
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
});
|
|
}
|
|
Message::Refresh => {
|
|
if let Some(NmState { ref conn, .. }) = self.nm_state {
|
|
return cosmic::Task::batch(vec![
|
|
update_state(conn.clone()),
|
|
update_devices(conn.clone()),
|
|
connection_settings(conn.clone()),
|
|
]);
|
|
}
|
|
}
|
|
Message::PasswordUpdate(pass) => {
|
|
if let Some(VpnDialog::Password {
|
|
ref mut password, ..
|
|
}) = self.dialog
|
|
{
|
|
*password = pass;
|
|
}
|
|
}
|
|
Message::ConnectWithPassword => {
|
|
let Some(dialog) = self.dialog.take() else {
|
|
return Task::none();
|
|
};
|
|
|
|
if let VpnDialog::Password {
|
|
id,
|
|
uuid,
|
|
username,
|
|
password,
|
|
tx,
|
|
..
|
|
} = dialog
|
|
{
|
|
let username_unwrapped = username.clone().unwrap_or_default();
|
|
let task = self.activate_with_password(
|
|
id.clone(),
|
|
uuid.clone(),
|
|
username_unwrapped.clone(),
|
|
password.clone(),
|
|
);
|
|
let sec_tx = self.secret_tx.clone();
|
|
return task
|
|
.then(move |_| {
|
|
let sec_tx = sec_tx.clone();
|
|
let uuid = uuid.clone();
|
|
let username = username.clone();
|
|
let password = password.clone();
|
|
let tx = tx.clone();
|
|
let id = id.clone();
|
|
Task::future(async move {
|
|
let mut guard = tx.lock().await;
|
|
if let Some(sender) = guard.take() {
|
|
let _ = sender.send(password);
|
|
} else {
|
|
// apply password and username then
|
|
if let Some(sec_tx) = sec_tx {
|
|
let (applied_tx, applied_rx) =
|
|
tokio::sync::oneshot::channel();
|
|
if let Err(err) = sec_tx
|
|
.send(nm_secret_agent::Request::SetSecrets {
|
|
setting_name: "vpn".to_string(),
|
|
uuid: uuid.to_string(),
|
|
secrets: HashMap::from_iter([
|
|
// username and password
|
|
(
|
|
"username".to_string(),
|
|
username.clone().unwrap_or_default().into(),
|
|
),
|
|
("password".to_string(), password.clone()),
|
|
]),
|
|
applied_tx,
|
|
})
|
|
.await
|
|
{
|
|
tracing::error!(%err, "failed to apply secret");
|
|
}
|
|
// wait max 1s for the applied signal
|
|
if let Err(err) = tokio::time::timeout(
|
|
std::time::Duration::from_secs(1),
|
|
applied_rx,
|
|
)
|
|
.await
|
|
{
|
|
tracing::error!(%err, "failed to apply secret");
|
|
}
|
|
}
|
|
// activate
|
|
if let Err(why) = nmcli::connect(&id).await {
|
|
return Message::VpnDialogError(VpnDialog::Password {
|
|
error: Some((
|
|
ErrorKind::Connect,
|
|
format!("failed to connect to VPN: {why}"),
|
|
)),
|
|
id: id.clone(),
|
|
uuid,
|
|
username: username.clone(),
|
|
description: None,
|
|
password,
|
|
password_hidden: true,
|
|
tx: Arc::new(Mutex::new(None)),
|
|
});
|
|
}
|
|
}
|
|
|
|
Message::Refresh
|
|
})
|
|
})
|
|
.map(crate::app::Message::from);
|
|
}
|
|
}
|
|
Message::RetryWithPassword => {
|
|
let Some(dialog) = self.dialog.take() else {
|
|
return Task::none();
|
|
};
|
|
|
|
if let VpnDialog::Password {
|
|
id,
|
|
uuid,
|
|
username,
|
|
password,
|
|
..
|
|
} = dialog
|
|
{
|
|
let username_unwrapped = username.unwrap_or_default();
|
|
let sec_tx = self.secret_tx.clone();
|
|
let task = self.activate_with_password(
|
|
id.clone(),
|
|
uuid.clone(),
|
|
username_unwrapped.clone(),
|
|
password.clone(),
|
|
);
|
|
return task
|
|
.then(move |_| {
|
|
let sec_tx = sec_tx.clone();
|
|
let uuid = uuid.clone();
|
|
let username = username_unwrapped.clone();
|
|
let password = password.clone();
|
|
Task::future(async move {
|
|
if let Some(sec_tx) = sec_tx {
|
|
let (applied_tx, applied_rx) = tokio::sync::oneshot::channel();
|
|
let _ = sec_tx
|
|
.send(nm_secret_agent::Request::SetSecrets {
|
|
setting_name: "vpn".to_string(),
|
|
uuid: uuid.to_string(),
|
|
secrets: HashMap::from_iter([
|
|
// username and password
|
|
("username".to_string(), username.clone().into()),
|
|
("password".to_string(), password.clone()),
|
|
]),
|
|
applied_tx,
|
|
})
|
|
.await;
|
|
// wait max 1s for the applied signal
|
|
let _ = tokio::time::timeout(
|
|
std::time::Duration::from_secs(1),
|
|
applied_rx,
|
|
)
|
|
.await;
|
|
}
|
|
Message::Activate(uuid)
|
|
})
|
|
})
|
|
.map(crate::app::Message::from);
|
|
}
|
|
}
|
|
Message::UsernameUpdate(user) => {
|
|
if let Some(VpnDialog::Password {
|
|
ref mut username, ..
|
|
}) = self.dialog
|
|
{
|
|
*username = Some(user);
|
|
}
|
|
}
|
|
Message::CancelDialog => {
|
|
self.dialog = None;
|
|
}
|
|
Message::TogglePasswordVisibility => {
|
|
if let Some(VpnDialog::Password {
|
|
ref mut password_hidden,
|
|
..
|
|
}) = self.dialog
|
|
{
|
|
*password_hidden = !*password_hidden;
|
|
}
|
|
}
|
|
Message::Error(error_kind, why) => {
|
|
tracing::error!(?error_kind, why);
|
|
self.dialog = Some(VpnDialog::Error(error_kind, why))
|
|
}
|
|
Message::NetworkManagerConnect(conn) => {
|
|
return self.connect(conn.clone());
|
|
}
|
|
Message::VpnDialogError(vpn_dialog) => {
|
|
self.dialog = Some(vpn_dialog);
|
|
return task::message(Message::FocusSecureInput);
|
|
}
|
|
Message::SecretAgent(e) => match e {
|
|
nm_secret_agent::Event::RequestSecret {
|
|
uuid: _,
|
|
name,
|
|
previous,
|
|
description,
|
|
tx,
|
|
} => {
|
|
self.dialog = Some(VpnDialog::Password {
|
|
id: name.clone(),
|
|
uuid: Arc::from(""),
|
|
username: None,
|
|
description,
|
|
password: previous,
|
|
password_hidden: true,
|
|
tx,
|
|
error: None,
|
|
});
|
|
return task::message(Message::FocusSecureInput);
|
|
}
|
|
nm_secret_agent::Event::CancelGetSecrets { uuid: _, name: _ } => {
|
|
self.dialog = self
|
|
.dialog
|
|
.take()
|
|
.filter(|d| !matches!(d, &VpnDialog::Password { .. }));
|
|
}
|
|
nm_secret_agent::Event::Failed(error) => {
|
|
tracing::error!(%error, "secret agent failure");
|
|
if let Some(VpnDialog::Password {
|
|
id,
|
|
uuid,
|
|
username,
|
|
password,
|
|
description,
|
|
..
|
|
}) = self.dialog.take()
|
|
{
|
|
self.dialog = Some(VpnDialog::Password {
|
|
error: Some((ErrorKind::DbusConnection, error.to_string())),
|
|
id,
|
|
uuid,
|
|
username,
|
|
description,
|
|
password,
|
|
password_hidden: true,
|
|
tx: Arc::new(Mutex::new(None)),
|
|
});
|
|
return task::message(Message::FocusSecureInput);
|
|
}
|
|
}
|
|
},
|
|
Message::FocusSecureInput => {
|
|
// retry until the widget is in the tree and focused or the dialog is removed.
|
|
if matches!(self.dialog, Some(VpnDialog::Password { .. })) {
|
|
return cosmic::iced::runtime::task::widget(
|
|
cosmic::iced::core::widget::operation::focusable::find_focused(),
|
|
)
|
|
.collect()
|
|
.then(|id| {
|
|
if id.first().is_some_and(|id| *id == SECURE_INPUT_VPN.clone()) {
|
|
Task::none()
|
|
} else {
|
|
focus(SECURE_INPUT_VPN.clone())
|
|
.chain(task::message(Message::FocusSecureInput))
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
|
|
fn activate_with_password(
|
|
&mut self,
|
|
connection_name: String,
|
|
uuid: Arc<str>,
|
|
username: String,
|
|
password: SecureString,
|
|
) -> Task<Message> {
|
|
cosmic::task::future(async move {
|
|
if let Err(why) = nmcli::set_username(&connection_name, &username).await {
|
|
return Message::VpnDialogError(VpnDialog::Password {
|
|
error: Some((ErrorKind::WithPassword("username"), why.to_string())),
|
|
id: connection_name.clone(),
|
|
uuid,
|
|
username: Some(username),
|
|
description: None,
|
|
password,
|
|
password_hidden: true,
|
|
tx: Arc::new(Mutex::new(None)),
|
|
});
|
|
}
|
|
|
|
if let Err(why) = nmcli::add_fallback(&connection_name).await {
|
|
return Message::VpnDialogError(VpnDialog::Password {
|
|
error: Some((ErrorKind::Config, why.to_string())),
|
|
id: connection_name.clone(),
|
|
uuid,
|
|
username: Some(username),
|
|
password,
|
|
description: None,
|
|
password_hidden: true,
|
|
tx: Arc::new(Mutex::new(None)),
|
|
});
|
|
}
|
|
|
|
Message::Refresh
|
|
})
|
|
}
|
|
|
|
fn connect(&mut self, conn: zbus::Connection) -> Task<crate::app::Message> {
|
|
if self.nm_task.is_none() {
|
|
let (canceller, task) =
|
|
crate::utils::forward_event_loop(move |mut sender| async move {
|
|
let (tx, mut rx) = futures::channel::mpsc::channel(1);
|
|
|
|
let watchers = std::pin::pin!(async move {
|
|
futures::join!(
|
|
network_manager::watch(conn.clone(), tx.clone()),
|
|
network_manager::active_conns::watch(conn.clone(), tx.clone()),
|
|
network_manager::devices::watch(conn, true, tx)
|
|
)
|
|
});
|
|
|
|
let forwarder = std::pin::pin!(async move {
|
|
while let Some(message) = rx.next().await {
|
|
_ = sender
|
|
.send(crate::pages::Message::Vpn(Message::NetworkManager(message)))
|
|
.await;
|
|
}
|
|
});
|
|
|
|
futures::future::select(watchers, forwarder).await;
|
|
});
|
|
|
|
self.nm_task = Some(canceller);
|
|
|
|
return task.map(crate::app::Message::from);
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
|
|
/// Closes the view more popup and applies any withheld updates.
|
|
fn close_popup_and_apply_updates(&mut self) {
|
|
self.view_more_popup = None;
|
|
if let Some(ref mut nm_state) = self.nm_state {
|
|
if let Some(active_conns) = self.withheld_active_conns.take() {
|
|
nm_state.active_conns = active_conns;
|
|
}
|
|
|
|
if let Some(devices) = self.withheld_devices.take() {
|
|
nm_state.devices = devices;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Withholds updates if the view more popup is displayed.
|
|
fn update_devices(&mut self, devices: Vec<network_manager::devices::DeviceInfo>) {
|
|
if let Some(ref mut nm_state) = self.nm_state {
|
|
if self.view_more_popup.is_some() {
|
|
self.withheld_devices = Some(devices);
|
|
} else {
|
|
nm_state.devices = devices;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Withholds updates if the view more popup is displayed.
|
|
fn update_active_conns(&mut self, state: NetworkManagerState) {
|
|
if let Some(ref mut nm_state) = self.nm_state {
|
|
let conns = state
|
|
.active_conns
|
|
.into_iter()
|
|
.filter(|info| matches!(info, ActiveConnectionInfo::Vpn { .. }))
|
|
.collect();
|
|
|
|
if self.view_more_popup.is_some() {
|
|
self.withheld_active_conns = Some(conns);
|
|
} else {
|
|
nm_state.active_conns = conns;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn devices_view() -> Section<crate::pages::Message> {
|
|
crate::slab!(descriptions {
|
|
vpn_conns_txt = fl!("vpn", "connections");
|
|
remove_txt = fl!("vpn", "remove");
|
|
connect_txt = fl!("connect");
|
|
connected_txt = fl!("connected");
|
|
settings_txt = fl!("settings");
|
|
disconnect_txt = fl!("disconnect");
|
|
});
|
|
|
|
Section::default()
|
|
.descriptions(descriptions)
|
|
.view::<Page>(move |_binder, page, section| {
|
|
let Some(NmState {
|
|
ref active_conns, ..
|
|
}) = page.nm_state
|
|
else {
|
|
return cosmic::widget::space().into();
|
|
};
|
|
|
|
let spacing = cosmic::theme::spacing();
|
|
|
|
let mut view = widget::column::with_capacity(4);
|
|
|
|
let vpn_connections =
|
|
widget::settings::section().title(§ion.descriptions[vpn_conns_txt]);
|
|
|
|
if page.known_connections.is_empty() {
|
|
view = view.push(vpn_connections.add(widget::settings::item_row(vec![
|
|
widget::text::body(fl!("no-vpn")).into(),
|
|
])));
|
|
} else {
|
|
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() == id,
|
|
|
|
_ => false,
|
|
});
|
|
|
|
let (connect_txt, connect_msg) = if is_connected {
|
|
(§ion.descriptions[connected_txt], None)
|
|
} else {
|
|
(
|
|
§ion.descriptions[connect_txt],
|
|
Some(Message::Activate(uuid.clone())),
|
|
)
|
|
};
|
|
|
|
let identifier = widget::text::body(id).wrapping(Wrapping::Glyph);
|
|
|
|
let connect: Element<'_, Message> = if let Some(msg) = connect_msg {
|
|
widget::button::text(connect_txt).on_press(msg).into()
|
|
} else {
|
|
widget::text::body(connect_txt)
|
|
.align_y(Alignment::Center)
|
|
.into()
|
|
};
|
|
|
|
let view_more_button =
|
|
widget::button::icon(widget::icon::from_name("view-more-symbolic"));
|
|
|
|
let view_more: Option<Element<_>> = if page
|
|
.view_more_popup
|
|
.as_deref()
|
|
.is_some_and(|id| id == uuid.as_ref())
|
|
{
|
|
widget::popover(view_more_button.on_press(Message::ViewMore(None)))
|
|
.position(widget::popover::Position::Bottom)
|
|
.on_close(Message::ViewMore(None))
|
|
.popup(
|
|
widget::column::with_capacity(3)
|
|
.push_maybe(is_connected.then(|| {
|
|
popup_button(
|
|
Message::Deactivate(uuid.clone()),
|
|
§ion.descriptions[disconnect_txt],
|
|
)
|
|
}))
|
|
.push(popup_button(
|
|
Message::Settings(uuid.clone()),
|
|
§ion.descriptions[settings_txt],
|
|
))
|
|
.push(popup_button(
|
|
Message::RemoveProfileRequest(uuid.clone()),
|
|
§ion.descriptions[remove_txt],
|
|
))
|
|
.width(Length::Fixed(200.0))
|
|
.apply(widget::container)
|
|
.padding(cosmic::theme::spacing().space_xxs)
|
|
.class(cosmic::theme::Container::Dropdown),
|
|
)
|
|
.apply(|e| Some(Element::from(e)))
|
|
} else {
|
|
view_more_button
|
|
.on_press(Message::ViewMore(Some(uuid.clone())))
|
|
.apply(|e| Some(Element::from(e)))
|
|
};
|
|
|
|
let controls = widget::row::with_capacity(2)
|
|
.push(connect)
|
|
.push_maybe(view_more)
|
|
.align_y(Alignment::Center)
|
|
.spacing(spacing.space_xxs);
|
|
|
|
let widget = widget::settings::item_row(vec![
|
|
identifier.into(),
|
|
horizontal_space().into(),
|
|
controls.into(),
|
|
]);
|
|
|
|
networks.add(widget)
|
|
},
|
|
);
|
|
|
|
view = view.push(known_networks);
|
|
}
|
|
|
|
view.spacing(spacing.space_l)
|
|
.apply(Element::from)
|
|
.map(crate::pages::Message::Vpn)
|
|
})
|
|
}
|
|
|
|
fn popup_button(message: Message, text: &str) -> Element<'_, Message> {
|
|
let spacing = cosmic::theme::spacing();
|
|
widget::text::body(text)
|
|
.align_y(Alignment::Center)
|
|
.apply(widget::button::custom)
|
|
.padding([spacing.space_xxxs, spacing.space_xs])
|
|
.width(Length::Fill)
|
|
.class(cosmic::theme::Button::MenuItem)
|
|
.on_press(message)
|
|
.into()
|
|
}
|
|
|
|
fn update_state(conn: zbus::Connection) -> Task<crate::app::Message> {
|
|
cosmic::task::future(async move {
|
|
match NetworkManagerState::new(&conn).await {
|
|
Ok(state) => Message::UpdateState(state),
|
|
Err(why) => Message::Error(ErrorKind::UpdatingState, why.to_string()),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn update_devices(conn: zbus::Connection) -> Task<crate::app::Message> {
|
|
cosmic::task::future(async move {
|
|
let filter =
|
|
|device_type| matches!(device_type, network_manager::devices::DeviceType::WireGuard);
|
|
|
|
match network_manager::devices::list(&conn, filter).await {
|
|
Ok(devices) => Message::UpdateDevices(devices),
|
|
Err(why) => Message::Error(ErrorKind::UpdatingState, why.to_string()),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn add_network() -> Task<crate::app::Message> {
|
|
let Some(dir) = dirs::download_dir().or_else(dirs::home_dir) else {
|
|
return Task::none();
|
|
};
|
|
|
|
cosmic::dialog::file_chooser::open::Dialog::new()
|
|
.directory(dir)
|
|
.title(fl!("vpn", "select-file"))
|
|
.filter(
|
|
FileFilter::new("OpenVPN")
|
|
.mimetype("application/x-openvpn-profile")
|
|
.glob("*.ovpn"),
|
|
)
|
|
.filter(FileFilter::new("WireGuard").glob("*.conf*"))
|
|
.open_file()
|
|
.then(|result| async move {
|
|
match result {
|
|
Ok(response) => {
|
|
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 {
|
|
super::nm_add_vpn_file("openvpn", response.url().to_file_path().unwrap())
|
|
.await
|
|
};
|
|
|
|
match result {
|
|
Ok(_) => Message::Refresh,
|
|
Err(why) => Message::Error(ErrorKind::Config, why.to_string()),
|
|
}
|
|
}
|
|
Err(cosmic::dialog::file_chooser::Error::Cancelled) => Message::CancelDialog,
|
|
Err(why) => Message::Error(ErrorKind::Config, why.to_string()),
|
|
}
|
|
})
|
|
.apply(cosmic::task::future)
|
|
}
|
|
|
|
fn connection_settings(conn: zbus::Connection) -> Task<crate::app::Message> {
|
|
let settings = async move {
|
|
let settings = network_manager::dbus::settings::NetworkManagerSettings::new(&conn).await?;
|
|
|
|
_ = settings.load_connections(&[]).await;
|
|
|
|
let settings = settings
|
|
// Get a list of known connections.
|
|
.list_connections()
|
|
.await?
|
|
// Prepare for wrapping in a concurrent stream.
|
|
.into_iter()
|
|
.map(|conn| async move { conn })
|
|
// Create a concurrent stream for each connection.
|
|
.apply(futures::stream::FuturesOrdered::from_iter)
|
|
// Concurrently fetch settings for each connection, and filter for VPN.
|
|
.filter_map(|conn| async move {
|
|
let settings = conn.get_settings().await.ok()?;
|
|
|
|
let connection = settings.get("connection")?;
|
|
|
|
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()?;
|
|
|
|
let (connection_type, username, password_flag) = vpn
|
|
.get("data")
|
|
.and_then(|data| data.downcast_ref::<zbus::zvariant::Dict>().ok())
|
|
.map(|dict| {
|
|
let (mut connection_type, mut password_flag) = (None, None);
|
|
let mut username = vpn
|
|
.get("user-name")
|
|
.and_then(|u| u.downcast_ref::<String>().ok());
|
|
if dict
|
|
.get::<String, String>(&String::from("connection-type"))
|
|
.ok()
|
|
.flatten()
|
|
.as_deref()
|
|
// may be "password" or "password-tls"
|
|
.is_some_and(|p| p.starts_with("password"))
|
|
{
|
|
connection_type = Some(ConnectionType::Password);
|
|
username = Some(username.unwrap_or_default());
|
|
|
|
password_flag = dict
|
|
.get::<String, String>(&String::from("password-flags"))
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|value| match value.as_str() {
|
|
"0" => Some(PasswordFlag::None),
|
|
"1" => Some(PasswordFlag::AgentOwned),
|
|
"2" => Some(PasswordFlag::NotSaved),
|
|
"4" => Some(PasswordFlag::NotRequired),
|
|
_ => None,
|
|
});
|
|
}
|
|
|
|
(connection_type, username, password_flag)
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Some((
|
|
Arc::from(uuid),
|
|
ConnectionSettings::Vpn(VpnConnectionSettings {
|
|
id,
|
|
connection_type,
|
|
password_flag,
|
|
username,
|
|
}),
|
|
))
|
|
})
|
|
// Reduce the settings list into
|
|
.fold(IndexMap::new(), |mut set, (uuid, data)| async move {
|
|
set.insert(uuid, data);
|
|
set
|
|
})
|
|
.await;
|
|
|
|
Ok::<_, zbus::Error>(settings)
|
|
};
|
|
|
|
cosmic::task::future(async move {
|
|
settings.await.map_or_else(
|
|
|why| Message::Error(ErrorKind::ConnectionSettings, why.to_string()),
|
|
Message::KnownConnections,
|
|
)
|
|
})
|
|
}
|