diff --git a/rust/connlib/shared/src/callbacks.rs b/rust/connlib/shared/src/callbacks.rs index a4efca9eb..7d5c23513 100644 --- a/rust/connlib/shared/src/callbacks.rs +++ b/rust/connlib/shared/src/callbacks.rs @@ -70,6 +70,14 @@ impl ResourceDescription { ResourceDescription::Internet(r) => &r.sites, } } + + pub fn can_toggle(&self) -> bool { + match self { + ResourceDescription::Dns(r) => r.can_toggle, + ResourceDescription::Cidr(r) => r.can_toggle, + ResourceDescription::Internet(r) => r.can_toggle, + } + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] diff --git a/rust/gui-client/src-tauri/src/client/gui.rs b/rust/gui-client/src-tauri/src/client/gui.rs index b2b80f376..316add6db 100644 --- a/rust/gui-client/src-tauri/src/client/gui.rs +++ b/rust/gui-client/src-tauri/src/client/gui.rs @@ -10,7 +10,10 @@ use crate::client::{ }; use anyhow::{anyhow, bail, Context, Result}; use firezone_bin_shared::{new_dns_notifier, new_network_notifier}; -use firezone_headless_client::{IpcClientMsg, IpcServerMsg}; +use firezone_headless_client::{ + IpcClientMsg::{self, SetDisabledResources}, + IpcServerMsg, +}; use secrecy::{ExposeSecret, SecretString}; use std::{ path::PathBuf, @@ -621,6 +624,14 @@ impl Controller { self.advanced_settings.favorite_resources.remove(&resource_id); self.refresh_favorite_resources().await?; } + Req::SystemTrayMenu(TrayMenuEvent::EnableResource(resource_id)) => { + self.advanced_settings.disabled_resources.remove(&resource_id); + self.update_disabled_resources().await?; + } + Req::SystemTrayMenu(TrayMenuEvent::DisableResource(resource_id)) => { + self.advanced_settings.disabled_resources.insert(resource_id); + self.update_disabled_resources().await?; + } Req::SystemTrayMenu(TrayMenuEvent::ShowWindow(window)) => { self.show_window(window)?; // When the About or Settings windows are hidden / shown, log the @@ -716,6 +727,9 @@ impl Controller { if let Err(error) = self.refresh_system_tray_menu() { tracing::error!(?error, "Failed to refresh Resource list"); } + + self.update_disabled_resources().await?; + Ok(()) } IpcServerMsg::TerminatingGracefully => { @@ -726,6 +740,27 @@ impl Controller { } } + async fn update_disabled_resources(&mut self) -> Result<()> { + settings::save(&self.advanced_settings).await?; + + let Status::TunnelReady { resources } = &self.status else { + bail!("Tunnel is not ready"); + }; + + let disabled_resources = resources + .iter() + .filter_map(|r| r.can_toggle().then_some(r.id())) + .filter(|id| self.advanced_settings.disabled_resources.contains(id)) + .collect(); + + self.ipc_client + .send_msg(&SetDisabledResources(disabled_resources)) + .await?; + self.refresh_system_tray_menu()?; + + Ok(()) + } + /// Saves the current settings (including favorites) to disk and refreshes the tray menu async fn refresh_favorite_resources(&mut self) -> Result<()> { settings::save(&self.advanced_settings).await?; @@ -748,6 +783,7 @@ impl Controller { system_tray::AppState::SignedIn(system_tray::SignedIn { actor_name: &auth_session.actor_name, favorite_resources: &self.advanced_settings.favorite_resources, + disabled_resources: &self.advanced_settings.disabled_resources, resources, }) } diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray.rs b/rust/gui-client/src-tauri/src/client/gui/system_tray.rs index 1f2d668a5..8fa754358 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray.rs +++ b/rust/gui-client/src-tauri/src/client/gui/system_tray.rs @@ -37,6 +37,8 @@ const RESOURCES: &str = "Resources"; const OTHER_RESOURCES: &str = "Other Resources"; const SIGN_OUT: &str = "Sign out"; const DISCONNECT_AND_QUIT: &str = "Disconnect and quit Firezone"; +const DISABLE: &str = "Disable this resource"; +const ENABLE: &str = "Enable this resource"; pub(crate) fn loading() -> SystemTray { SystemTray::new() @@ -62,6 +64,7 @@ pub(crate) struct SignedIn<'a> { pub(crate) actor_name: &'a str, pub(crate) favorite_resources: &'a HashSet, pub(crate) resources: &'a [ResourceDescription], + pub(crate) disabled_resources: &'a HashSet, } impl<'a> SignedIn<'a> { @@ -72,19 +75,30 @@ impl<'a> SignedIn<'a> { /// Builds the submenu that has the resource address, name, desc, /// sites online, etc. fn resource_submenu(&self, res: &ResourceDescription) -> Menu { - let submenu = Menu::default().add_item(resource_header(res)); + let mut submenu = Menu::default(); - let submenu = submenu + submenu.add_item(resource_header(res)); + + let mut submenu = submenu .separator() .disabled("Resource") .copyable(res.name()) .copyable(res.pastable().as_ref()); - let submenu = if self.is_favorite(res) { - submenu.add_item(item(Event::RemoveFavorite(res.id()), REMOVE_FAVORITE).selected()) + if self.is_favorite(res) { + submenu.add_item(item(Event::RemoveFavorite(res.id()), REMOVE_FAVORITE).selected()); } else { - submenu.item(Event::AddFavorite(res.id()), ADD_FAVORITE) - }; + submenu.add_item(item(Event::AddFavorite(res.id()), ADD_FAVORITE)); + } + + if res.can_toggle() { + submenu.add_separator(); + if self.is_enabled(res) { + submenu.add_item(item(Event::DisableResource(res.id()), DISABLE)); + } else { + submenu.add_item(item(Event::EnableResource(res.id()), ENABLE)); + } + } if let Some(site) = res.sites().first() { // Emojis may be causing an issue on some Ubuntu desktop environments. @@ -103,6 +117,10 @@ impl<'a> SignedIn<'a> { submenu } } + + fn is_enabled(&self, res: &ResourceDescription) -> bool { + !self.disabled_resources.contains(&res.id()) + } } fn resource_header(res: &ResourceDescription) -> Item { @@ -207,6 +225,7 @@ fn signed_in(signed_in: &SignedIn) -> Menu { actor_name, favorite_resources, resources, // Make sure these are presented in the order we receive them + .. } = signed_in; let has_any_favorites = resources @@ -270,6 +289,30 @@ mod tests { use anyhow::Result; use std::str::FromStr as _; + impl Menu { + fn selected_item>, S: Into>( + mut self, + id: E, + title: S, + ) -> Self { + self.add_item(item(id, title).selected()); + self + } + } + + fn signed_in<'a>( + resources: &'a [ResourceDescription], + favorite_resources: &'a HashSet, + disabled_resources: &'a HashSet, + ) -> AppState<'a> { + AppState::SignedIn(SignedIn { + actor_name: "Jane Doe", + favorite_resources, + resources, + disabled_resources, + }) + } + fn resources() -> Vec { let s = r#"[ { @@ -311,11 +354,8 @@ mod tests { fn no_resources_no_favorites() { let resources = vec![]; let favorites = Default::default(); - let input = AppState::SignedIn(SignedIn { - actor_name: "Jane Doe", - favorite_resources: &favorites, - resources: &resources, - }); + let disabled_resources = Default::default(); + let input = signed_in(&resources, &favorites, &disabled_resources); let actual = input.into_menu(); let expected = Menu::default() .disabled("Signed in as Jane Doe") @@ -336,11 +376,8 @@ mod tests { fn no_resources_invalid_favorite() { let resources = vec![]; let favorites = HashSet::from([ResourceId::from_u128(42)]); - let input = AppState::SignedIn(SignedIn { - actor_name: "Jane Doe", - favorite_resources: &favorites, - resources: &resources, - }); + let disabled_resources = Default::default(); + let input = signed_in(&resources, &favorites, &disabled_resources); let actual = input.into_menu(); let expected = Menu::default() .disabled("Signed in as Jane Doe") @@ -361,11 +398,8 @@ mod tests { fn some_resources_no_favorites() { let resources = resources(); let favorites = Default::default(); - let input = AppState::SignedIn(SignedIn { - actor_name: "Jane Doe", - favorite_resources: &favorites, - resources: &resources, - }); + let disabled_resources = Default::default(); + let input = signed_in(&resources, &favorites, &disabled_resources); let actual = input.into_menu(); let expected = Menu::default() .disabled("Signed in as Jane Doe") @@ -447,11 +481,8 @@ mod tests { let favorites = HashSet::from([ResourceId::from_str( "03000143-e25e-45c7-aafb-144990e57dcd", )?]); - let input = AppState::SignedIn(SignedIn { - actor_name: "Jane Doe", - favorite_resources: &favorites, - resources: &resources, - }); + let disabled_resources = Default::default(); + let input = signed_in(&resources, &favorites, &disabled_resources); let actual = input.into_menu(); let expected = Menu::default() .disabled("Signed in as Jane Doe") @@ -469,14 +500,11 @@ mod tests { .disabled("Resource") .copyable("MyCorp GitLab") .copyable("gitlab.mycorp.com") - .add_item( - item( - Event::RemoveFavorite(ResourceId::from_str( - "03000143-e25e-45c7-aafb-144990e57dcd", - )?), - REMOVE_FAVORITE, - ) - .selected(), + .selected_item( + Event::RemoveFavorite(ResourceId::from_str( + "03000143-e25e-45c7-aafb-144990e57dcd", + )?), + REMOVE_FAVORITE, ) .separator() .disabled("Site") @@ -544,11 +572,8 @@ mod tests { let favorites = HashSet::from([ResourceId::from_str( "00000000-0000-0000-0000-000000000000", )?]); - let input = AppState::SignedIn(SignedIn { - actor_name: "Jane Doe", - favorite_resources: &favorites, - resources: &resources, - }); + let disabled_resources = Default::default(); + let input = signed_in(&resources, &favorites, &disabled_resources); let actual = input.into_menu(); let expected = Menu::default() .disabled("Signed in as Jane Doe") diff --git a/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs b/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs index 0ecb3b267..0671efb7e 100644 --- a/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs +++ b/rust/gui-client/src-tauri/src/client/gui/system_tray/builder.rs @@ -63,6 +63,10 @@ pub(crate) enum Event { Url(Url), /// Quits the app, without signing the user out Quit, + /// A resource was enabled in the UI + EnableResource(ResourceId), + /// A resource was disabled in the UI + DisableResource(ResourceId), } #[derive(Debug, Deserialize, PartialEq, Serialize)] @@ -94,9 +98,12 @@ impl Menu { .item(Event::Quit, quit_text) } - pub(crate) fn add_item(mut self, item: Item) -> Self { + pub(crate) fn add_separator(&mut self) { + self.entries.push(Entry::Separator); + } + + pub(crate) fn add_item(&mut self, item: Item) { self.entries.push(Entry::Item(item)); - self } pub(crate) fn add_submenu>(mut self, title: S, inner: Menu) -> Self { @@ -125,23 +132,26 @@ impl Menu { } /// Appends a menu item that copies its title when clicked - pub(crate) fn copyable(self, s: &str) -> Self { - self.add_item(copyable(s)) + pub(crate) fn copyable(mut self, s: &str) -> Self { + self.add_item(copyable(s)); + self } /// Appends a disabled item with no accelerator or event - pub(crate) fn disabled>(self, title: S) -> Self { - self.add_item(item(None, title).disabled()) + pub(crate) fn disabled>(mut self, title: S) -> Self { + self.add_item(item(None, title).disabled()); + self } /// Appends a generic menu item - pub(crate) fn item>, S: Into>(self, id: E, title: S) -> Self { - self.add_item(item(id, title)) + pub(crate) fn item>, S: Into>(mut self, id: E, title: S) -> Self { + self.add_item(item(id, title)); + self } /// Appends a separator pub(crate) fn separator(mut self) -> Self { - self.entries.push(Entry::Separator); + self.add_separator(); self } } diff --git a/rust/gui-client/src-tauri/src/client/settings.rs b/rust/gui-client/src-tauri/src/client/settings.rs index dbe9c75d5..ff0e4140d 100644 --- a/rust/gui-client/src-tauri/src/client/settings.rs +++ b/rust/gui-client/src-tauri/src/client/settings.rs @@ -15,9 +15,10 @@ use url::Url; pub(crate) struct AdvancedSettings { pub auth_base_url: Url, pub api_url: Url, - // TODO: Will this cause problems when we add the ability to enable / disable Resources on the client side? At least the Internet Resource will need that. #[serde(default)] pub favorite_resources: HashSet, + #[serde(default)] + pub disabled_resources: HashSet, pub log_filter: String, } @@ -28,6 +29,7 @@ impl Default for AdvancedSettings { auth_base_url: Url::parse("https://app.firez.one").unwrap(), api_url: Url::parse("wss://api.firez.one").unwrap(), favorite_resources: Default::default(), + disabled_resources: Default::default(), log_filter: "firezone_gui_client=debug,info".to_string(), } } @@ -40,6 +42,7 @@ impl Default for AdvancedSettings { auth_base_url: Url::parse("https://app.firezone.dev").unwrap(), api_url: Url::parse("wss://api.firezone.dev").unwrap(), favorite_resources: Default::default(), + disabled_resources: Default::default(), log_filter: "info".to_string(), } } diff --git a/rust/headless-client/src/ipc_service.rs b/rust/headless-client/src/ipc_service.rs index ac346a0cb..771433bca 100644 --- a/rust/headless-client/src/ipc_service.rs +++ b/rust/headless-client/src/ipc_service.rs @@ -14,7 +14,7 @@ use futures::{ task::{Context, Poll}, Future as _, SinkExt as _, Stream as _, }; -use std::{net::IpAddr, path::PathBuf, pin::pin, sync::Arc, time::Duration}; +use std::{collections::HashSet, net::IpAddr, path::PathBuf, pin::pin, sync::Arc, time::Duration}; use tokio::{sync::mpsc, time::Instant}; use tracing::subscriber::set_global_default; use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Layer, Registry}; @@ -22,7 +22,7 @@ use url::Url; pub mod ipc; use backoff::ExponentialBackoffBuilder; -use connlib_shared::{get_user_agent, DEFAULT_MTU}; +use connlib_shared::{get_user_agent, messages::ResourceId, DEFAULT_MTU}; use ipc::{Server as IpcServer, ServiceId}; use phoenix_channel::PhoenixChannel; use secrecy::Secret; @@ -76,6 +76,7 @@ pub enum ClientMsg { Disconnect, Reset, SetDns(Vec), + SetDisabledResources(HashSet), } /// Only called from the GUI Client's build of the IPC service @@ -343,7 +344,8 @@ impl<'a> Handler<'a> { .context("Error while sending IPC message `OnUpdateResources`")?; } ConnlibMsg::OnUpdateRoutes { ipv4, ipv6 } => { - self.tun_device.set_routes(ipv4, ipv6).await? + self.tun_device.set_routes(ipv4, ipv6).await?; + self.dns_controller.flush()?; } } Ok(()) @@ -420,6 +422,12 @@ impl<'a> Handler<'a> { .as_mut() .context("No connlib session")? .set_dns(v), + ClientMsg::SetDisabledResources(disabled_resources) => { + self.connlib + .as_mut() + .context("No connlib session")? + .set_disabled_resources(disabled_resources); + } } Ok(()) }