diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b168480b1..f938b3258 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -27,6 +27,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "admx-macro" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "roxmltree", + "syn 2.0.101", +] + [[package]] name = "aead" version = "0.5.2" @@ -2162,6 +2172,7 @@ dependencies = [ name = "firezone-gui-client" version = "1.5.0" dependencies = [ + "admx-macro", "anyhow", "arboard", "atomicwrites", @@ -5744,6 +5755,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df6368f71f205ff9c33c076d170dd56ebf68e8161c733c0caa07a7a5509ed53" +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rtnetlink" version = "0.14.1" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a034d1a97..15218438b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -18,6 +18,7 @@ members = [ "connlib/tun", "connlib/tunnel", "gateway", + "gui-client/src-admx-macro", "gui-client/src-tauri", "headless-client", "logging", @@ -36,6 +37,7 @@ license = "Apache-2.0" edition = "2024" [workspace.dependencies] +admx-macro = { path = "gui-client/src-admx-macro" } android-client-ffi = { path = "android-client-ffi" } anyhow = "1.0.98" apple-client-ffi = { path = "apple-client-ffi" } @@ -117,9 +119,11 @@ output_vt100 = "0.1" parking_lot = "0.12.3" phoenix-channel = { path = "connlib/phoenix-channel" } png = "0.17.16" +proc-macro2 = "1.0" proptest = "1.6.0" proptest-state-machine = "0.3.1" quinn-udp = { version = "0.5.12", features = ["fast-apple-datapath"] } +quote = "1.0" rand = "0.8.5" rand_core = "0.6.4" rangemap = "1.5.1" @@ -127,6 +131,7 @@ rayon = "1.10.0" reqwest = { version = "0.12.9", default-features = false } resolv-conf = "0.7.3" ringbuffer = "0.15.0" +roxmltree = "0.20" rtnetlink = { version = "0.14.1", default-features = false, features = ["tokio_socket"] } rustls = { version = "0.23.21", default-features = false, features = ["ring"] } sadness-generator = "0.6.0" @@ -154,6 +159,7 @@ subtle = "2.5.0" supports-color = "3.0.2" swift-bridge = "0.1.57" swift-bridge-build = "0.1.57" +syn = "2.0" tauri = "2.5.1" tauri-build = "2.1.0" tauri-plugin-dialog = "2.2.1" diff --git a/rust/gui-client/src-admx-macro/Cargo.toml b/rust/gui-client/src-admx-macro/Cargo.toml new file mode 100644 index 000000000..921a33a5e --- /dev/null +++ b/rust/gui-client/src-admx-macro/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "admx-macro" +version = "0.1.0" +edition = { workspace = true } +description = "Proc macro to generate Windows registry loading code from ADMX policy templates" +license = { workspace = true } + +[lib] +path = "lib.rs" +proc-macro = true +test = false # Somehow buggy, tests don't compile on CI? + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +roxmltree = { workspace = true } +syn = { workspace = true, features = ["full"] } + +[lints] +workspace = true diff --git a/rust/gui-client/src-admx-macro/lib.rs b/rust/gui-client/src-admx-macro/lib.rs new file mode 100644 index 000000000..b41b2356c --- /dev/null +++ b/rust/gui-client/src-admx-macro/lib.rs @@ -0,0 +1,179 @@ +use proc_macro::TokenStream; +use proc_macro2::Span; +use std::collections::BTreeMap; +use syn::{ + ItemStruct, LitStr, Path, Token, + parse::{Parse, ParseStream}, + spanned::Spanned, +}; + +/// A proc-macro that maps struct fields to registry values defined by an ADMX template. +#[proc_macro_attribute] +pub fn admx(attr: TokenStream, item: TokenStream) -> TokenStream { + try_admx(attr, item) + .unwrap_or_else(|e| e.into_compile_error()) + .into() +} + +fn try_admx(attr: TokenStream, item: TokenStream) -> syn::Result { + let admx_path = syn::parse::(attr)?; + let input = syn::parse::(item)?; + + let admx_xml = ::std::fs::read_to_string(admx_path.inner.value()) + .map_err(|e| syn::Error::new(admx_path.span, format!("Failed to read ADMX file: {e}")))?; + let doc = ::roxmltree::Document::parse(&admx_xml) + .map_err(|e| syn::Error::new(admx_path.span, format!("Failed to parse ADMX XML: {e}")))?; + + let mut policy_map = doc + .descendants() + .filter(|n| n.has_tag_name("policy")) + .map(|policy| { + let value_name = policy.attribute("valueName").ok_or_else(|| { + syn::Error::new( + admx_path.inner.span(), + "Policy does not have a `valueName` attribute", + ) + })?; + let key = policy.attribute("key").ok_or_else(|| { + syn::Error::new( + admx_path.inner.span(), + format!("Policy '{value_name}' does not have a `key` attribute"), + ) + })?; + let span = proc_macro2::Span::call_site(); + let typ = policy + .descendants() + .find(|n| n.has_tag_name("text") || n.has_tag_name("decimal")) + .map(|el| PolicyType::from_str(el.tag_name().name(), span)) + .unwrap_or_else(|| { + Err(syn::Error::new( + span, + format!( + "No supported type element found for policy '{}'", + value_name + ), + )) + })?; + + let load_policy_value = match typ { + PolicyType::Text => quote::quote! { + { + let result = ::winreg::RegKey::predef(::winreg::enums::HKEY_CURRENT_USER) + .open_subkey(#key) + .and_then(|k| k.get_value(#value_name)); + ::tracing::debug!(target: ::core::module_path!(), key = concat!(#key, "\\", #value_name), ?result); + result.ok() + } + }, + PolicyType::Decimal => quote::quote! { + { + let result = ::winreg::RegKey::predef(::winreg::enums::HKEY_CURRENT_USER) + .open_subkey(#key) + .and_then(|k| k.get_value::(#value_name)); + ::tracing::debug!(target: ::core::module_path!(), key = concat!(#key, "\\", #value_name), ?result); + result.map(|v| v == 1).ok() + } + }, + }; + + Ok((value_name.to_string(), load_policy_value)) + }) + .collect::>>()?; + + let field_loads = input + .fields + .iter() + .map(|field| { + let field_ident = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new(field.span(), "Only named fields are supported"))?; + let policy_name = field_ident.to_string(); + + let load_policy_value = policy_map + .remove(&policy_name) + .ok_or_else(|| syn::Error::new(field.span(), "No ADMX policy found"))?; + + Ok(quote::quote! { + #field_ident: #load_policy_value + }) + }) + .collect::>>()?; + + #[expect(clippy::manual_try_fold, reason = "We need to start with `Ok(())`")] + policy_map + .into_iter() + .fold(Ok(()), |acc, (value_name, _)| { + let err = syn::Error::new( + admx_path.inner.span(), + format!("ADMX policy `{value_name}` is not mapped to any struct field",), + ); + + match acc { + Ok(()) => Err(err), + Err(mut errors) => { + errors.combine(err); + + Err(errors) + } + } + })?; + + let struct_name = &input.ident; + + Ok(quote::quote! { + #input + + impl #struct_name { + pub fn load_from_registry() -> ::anyhow::Result { + Ok(Self { + #(#field_loads,)* + }) + } + } + }) +} + +struct AdmxPath { + inner: LitStr, + span: Span, +} + +impl Parse for AdmxPath { + fn parse(input: ParseStream) -> syn::Result { + let path = input.parse::()?; + input.parse::()?; + let value = input.parse::()?; + + if !path.is_ident("path") { + return Err(syn::Error::new( + path.span(), + r#"Expected a single key `path`: `#[admx(path = "")]`"#, + )); + } + + Ok(AdmxPath { + inner: value, + span: input.span(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PolicyType { + Text, + Decimal, +} + +impl PolicyType { + fn from_str(s: &str, span: proc_macro2::Span) -> Result { + match s { + "text" => Ok(PolicyType::Text), + "decimal" => Ok(PolicyType::Decimal), + other => Err(syn::Error::new( + span, + format!("Unsupported ADMX policy type: {other}"), + )), + } + } +} diff --git a/rust/gui-client/src-tauri/Cargo.toml b/rust/gui-client/src-tauri/Cargo.toml index 087582c66..ae871cbc4 100644 --- a/rust/gui-client/src-tauri/Cargo.toml +++ b/rust/gui-client/src-tauri/Cargo.toml @@ -75,6 +75,7 @@ sd-notify = { workspace = true } tauri-winrt-notification = "0.7.2" winreg = { workspace = true } windows-service = { workspace = true } +admx-macro = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies.windows] workspace = true diff --git a/rust/gui-client/src-tauri/build.rs b/rust/gui-client/src-tauri/build.rs index f142e3d55..969e31ef6 100644 --- a/rust/gui-client/src-tauri/build.rs +++ b/rust/gui-client/src-tauri/build.rs @@ -2,5 +2,8 @@ fn main() -> anyhow::Result<()> { let win = tauri_build::WindowsAttributes::new(); let attr = tauri_build::Attributes::new().windows_attributes(win); tauri_build::try_build(attr)?; + + println!("cargo:rerun-if-changed=../website/public/policy-templates/windows/firezone.admx"); + Ok(()) } diff --git a/rust/gui-client/src-tauri/src/auth.rs b/rust/gui-client/src-tauri/src/auth.rs index ddc28c676..e4c1ea26c 100644 --- a/rust/gui-client/src-tauri/src/auth.rs +++ b/rust/gui-client/src-tauri/src/auth.rs @@ -59,8 +59,13 @@ pub struct Request { } impl Request { - pub fn to_url(&self, auth_base_url: &Url) -> SecretString { + pub fn to_url(&self, auth_base_url: &Url, account_slug: Option<&str>) -> SecretString { let mut url = auth_base_url.clone(); + + if let Some(account_slug) = account_slug { + url.set_path(account_slug); + } + url.query_pairs_mut() .append_pair("as", "client") .append_pair("nonce", self.nonce.expose_secret()) @@ -435,7 +440,7 @@ mod tests { state: bogus_secret("some_state"), }; assert_eq!( - req.to_url(&auth_base_url).expose_secret(), + req.to_url(&auth_base_url, None).expose_secret(), "https://app.firez.one/?as=client&nonce=some_nonce&state=some_state" ); } diff --git a/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs b/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs index 4d8708779..98af32d49 100644 --- a/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs +++ b/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs @@ -28,24 +28,11 @@ fn main() -> ExitCode { .install_default() .expect("Calling `install_default` only once per process should always succeed"); - let settings = settings::load_advanced_settings::().unwrap_or_default(); - let mut telemetry = Telemetry::default(); - telemetry.start( - settings.api_url.as_ref(), - firezone_gui_client::RELEASE, - firezone_telemetry::GUI_DSN, - ); - - // Get the device ID before starting Tokio, so that all the worker threads will inherit the correct scope. - // Technically this means we can fail to get the device ID on a newly-installed system, since the Tunnel service may not have fully started up when the GUI process reaches this point, but in practice it's unlikely. - if let Ok(id) = firezone_bin_shared::device_id::get() { - Telemetry::set_firezone_id(id.id); - } - + let settings = settings::load_advanced_settings::().unwrap_or_default(); let rt = tokio::runtime::Runtime::new().expect("Couldn't start Tokio runtime"); - match try_main(cli, &rt, settings) { + match try_main(cli, &rt, &mut telemetry, settings) { Ok(()) => { rt.block_on(telemetry.stop()); @@ -64,6 +51,7 @@ fn main() -> ExitCode { fn try_main( cli: Cli, rt: &tokio::runtime::Runtime, + telemetry: &mut Telemetry, mut settings: AdvancedSettingsLegacy, ) -> Result<()> { let config = gui::RunConfig { @@ -128,7 +116,7 @@ fn try_main( } Some(Cmd::SmokeTest) => { // Can't check elevation here because the Windows CI is always elevated - gui::run(rt, config, settings, reloader)?; + gui::run(rt, telemetry, config, settings, reloader)?; return Ok(()); } @@ -136,7 +124,7 @@ fn try_main( // Happy-path: Run the GUI. - match gui::run(rt, config, settings, reloader) { + match gui::run(rt, telemetry, config, settings, reloader) { Ok(()) => {} Err(anyhow) => { if anyhow diff --git a/rust/gui-client/src-tauri/src/controller.rs b/rust/gui-client/src-tauri/src/controller.rs index fd14f1f27..124ce85bd 100644 --- a/rust/gui-client/src-tauri/src/controller.rs +++ b/rust/gui-client/src-tauri/src/controller.rs @@ -3,7 +3,7 @@ use crate::{ gui::{self, system_tray}, ipc::{self, SocketId}, logging, service, - settings::{self, AdvancedSettings, GeneralSettings}, + settings::{self, AdvancedSettings, GeneralSettings, MdmSettings}, updates, uptime, }; use anyhow::{Context, Result, anyhow, bail}; @@ -33,6 +33,7 @@ pub type CtlrTx = mpsc::Sender; pub struct Controller { general_settings: GeneralSettings, + mdm_settings: MdmSettings, advanced_settings: AdvancedSettings, // Sign-in state with the portal / deep links auth: auth::Auth, @@ -70,7 +71,11 @@ pub trait GuiIntegration { fn notify_signed_in(&self, session: &auth::Session) -> Result<()>; fn notify_signed_out(&self) -> Result<()>; - fn notify_settings_changed(&self, settings: &AdvancedSettings) -> Result<()>; + fn notify_settings_changed( + &self, + mdm_settings: MdmSettings, + advanced_settings: AdvancedSettings, + ) -> Result<()>; /// Also opens non-URLs fn open_url>(&self, url: P) -> Result<()>; @@ -80,7 +85,11 @@ pub trait GuiIntegration { fn show_notification(&self, title: &str, body: &str) -> Result<()>; fn show_update_notification(&self, ctlr_tx: CtlrTx, title: &str, url: url::Url) -> Result<()>; - fn show_settings_window(&self, settings: &AdvancedSettings) -> Result<()>; + fn show_settings_window( + &self, + mdm_settings: MdmSettings, + advanced_settings: AdvancedSettings, + ) -> Result<()>; fn show_about_window(&self) -> Result<()>; } @@ -211,6 +220,7 @@ impl Controller { integration: I, rx: mpsc::Receiver, general_settings: GeneralSettings, + mdm_settings: MdmSettings, advanced_settings: AdvancedSettings, log_filter_reloader: FilterReloadHandle, updates_rx: mpsc::Receiver>, @@ -230,6 +240,7 @@ impl Controller { let controller = Controller { general_settings, + mdm_settings, advanced_settings, auth: auth::Auth::new()?, clear_logs_callback: None, @@ -266,12 +277,16 @@ impl Controller { .token() .context("Failed to load token from disk during app start")? { - self.start_session(token).await?; + // For backwards-compatibility prior to MDM-config, also call `start_session` if not configured. + if self.mdm_settings.connect_on_start.is_none_or(|c| c) { + self.start_session(token).await?; + } } else { tracing::info!("No token / actor_name on disk, starting in signed-out state"); - self.refresh_system_tray_menu(); } + self.refresh_system_tray_menu(); + if !ran_before::get().await? { self.integration .set_welcome_window_visible(true, self.auth.session())?; @@ -324,7 +339,7 @@ impl Controller { self.handle_update_notification(notification)? } EventloopTick::UpdateNotification(None) => { - return Err(anyhow!("Update checker task stopped")); + // Update task may be disabled by MDM, ignore if it stops / is not running. } EventloopTick::NewInstanceLaunched(None) => { return Err(anyhow!("GUI IPC socket closed")); @@ -400,7 +415,7 @@ impl Controller { ))?, } - let api_url = self.advanced_settings.api_url.clone(); + let api_url = self.api_url().clone(); tracing::info!(api_url = api_url.to_string(), "Starting connlib..."); // Count the start instant from before we connect @@ -426,7 +441,7 @@ impl Controller { } async fn update_telemetry_context(&mut self) -> Result<()> { - let environment = self.advanced_settings.api_url.to_string(); + let environment = self.api_url().to_string(); let account_slug = self.auth.session().map(|s| s.account_slug.to_owned()); if let Some(account_slug) = account_slug.clone() { @@ -482,8 +497,10 @@ impl Controller { .await?; // Notify GUI that settings have changed - self.integration - .notify_settings_changed(&self.advanced_settings)?; + self.integration.notify_settings_changed( + self.mdm_settings.clone(), + self.advanced_settings.clone(), + )?; tracing::debug!("Applied new settings. Log level will take effect immediately."); @@ -513,12 +530,13 @@ impl Controller { Fail(Failure::Error) => Err(anyhow!("Test error"))?, Fail(Failure::Panic) => panic!("Test panic"), SignIn | SystemTrayMenu(system_tray::Event::SignIn) => { + let auth_url = self.auth_url().clone(); let req = self .auth .start_sign_in() .context("Couldn't start sign-in flow")?; - let url = req.to_url(&self.advanced_settings.auth_base_url); + let url = req.to_url(&auth_url, self.mdm_settings.account_slug.as_deref()); self.refresh_system_tray_menu(); self.integration .open_url(url.expose_secret()) @@ -532,7 +550,7 @@ impl Controller { } SystemTrayMenu(system_tray::Event::AdminPortal) => self .integration - .open_url(&self.advanced_settings.auth_base_url) + .open_url(self.auth_url()) .context("Couldn't open auth page")?, SystemTrayMenu(system_tray::Event::Copy(s)) => arboard::Clipboard::new() .context("Couldn't access clipboard")? @@ -576,9 +594,10 @@ impl Controller { SystemTrayMenu(system_tray::Event::ShowWindow(window)) => { match window { system_tray::Window::About => self.integration.show_about_window()?, - system_tray::Window::Settings => self - .integration - .show_settings_window(&self.advanced_settings)?, + system_tray::Window::Settings => self.integration.show_settings_window( + self.mdm_settings.clone(), + self.advanced_settings.clone(), + )?, }; // When the About or Settings windows are hidden / shown, log the @@ -840,7 +859,9 @@ impl Controller { let connlib = if let Some(auth_session) = self.auth.session() { match &self.status { Status::Disconnected => { - tracing::error!("We have an auth session but no connlib session"); + // If we have an `auth_session` but no connlib session, we are most likely configured to + // _not_ auto-connect on startup. Thus, we treat this the same as being signed out. + system_tray::ConnlibState::SignedOut } Status::Quitting => system_tray::ConnlibState::Quitting, @@ -866,6 +887,11 @@ impl Controller { self.integration.set_tray_menu(system_tray::AppState { connlib, release: self.release.clone(), + hide_admin_portal_menu_item: self + .mdm_settings + .hide_admin_portal_menu_item + .is_some_and(|hide| hide), + support_url: self.mdm_settings.support_url.clone(), }); } @@ -912,6 +938,20 @@ impl Controller { .await .context("Failed to send IPC message") } + + fn auth_url(&self) -> &Url { + self.mdm_settings + .auth_url + .as_ref() + .unwrap_or(&self.advanced_settings.auth_base_url) + } + + fn api_url(&self) -> &Url { + self.mdm_settings + .api_url + .as_ref() + .unwrap_or(&self.advanced_settings.api_url) + } } async fn new_dns_notifier() -> Result>> { diff --git a/rust/gui-client/src-tauri/src/gui.rs b/rust/gui-client/src-tauri/src/gui.rs index 8746a3299..237af4b48 100644 --- a/rust/gui-client/src-tauri/src/gui.rs +++ b/rust/gui-client/src-tauri/src/gui.rs @@ -9,11 +9,14 @@ use crate::{ deep_link, ipc::{self, ClientRead, ClientWrite, SocketId}, logging, - settings::{self, AdvancedSettings, AdvancedSettingsLegacy}, + settings::{ + self, AdvancedSettings, AdvancedSettingsLegacy, AdvancedSettingsViewModel, MdmSettings, + }, updates, }; use anyhow::{Context, Result, bail}; use firezone_logging::err_with_src; +use firezone_telemetry::Telemetry; use futures::SinkExt as _; use std::time::Duration; use tauri::{Emitter, Manager}; @@ -119,9 +122,16 @@ impl GuiIntegration for TauriIntegration { Ok(()) } - fn notify_settings_changed(&self, settings: &AdvancedSettings) -> Result<()> { + fn notify_settings_changed( + &self, + mdm_settings: MdmSettings, + advanced_settings: AdvancedSettings, + ) -> Result<()> { self.app - .emit("settings_changed", settings) + .emit( + "settings_changed", + AdvancedSettingsViewModel::new(mdm_settings, advanced_settings), + ) .context("Failed to send `settings_changed` event")?; Ok(()) @@ -149,9 +159,13 @@ impl GuiIntegration for TauriIntegration { os::show_update_notification(&self.app, ctlr_tx, title, url) } - fn show_settings_window(&self, settings: &AdvancedSettings) -> Result<()> { + fn show_settings_window( + &self, + mdm_settings: MdmSettings, + advanced_settings: AdvancedSettings, + ) -> Result<()> { self.show_window("settings")?; - self.notify_settings_changed(settings)?; // Ensure settings are up to date in GUI. + self.notify_settings_changed(mdm_settings, advanced_settings)?; // Ensure settings are up to date in GUI. Ok(()) } @@ -187,10 +201,31 @@ pub enum ServerMsg { #[instrument(skip_all)] pub fn run( rt: &tokio::runtime::Runtime, + telemetry: &mut Telemetry, config: RunConfig, advanced_settings: AdvancedSettingsLegacy, reloader: firezone_logging::FilterReloadHandle, ) -> Result<()> { + let mdm_settings = settings::load_mdm_settings() + .inspect_err(|e| tracing::debug!("Failed to load MDM settings {e:#}")) + .unwrap_or_default(); + + telemetry.start( + mdm_settings + .api_url + .as_ref() + .unwrap_or(&advanced_settings.api_url) + .as_str(), + crate::RELEASE, + firezone_telemetry::GUI_DSN, + ); + + // Get the device ID before starting Tokio, so that all the worker threads will inherit the correct scope. + // Technically this means we can fail to get the device ID on a newly-installed system, since the Tunnel service may not have fully started up when the GUI process reaches this point, but in practice it's unlikely. + if let Ok(id) = firezone_bin_shared::device_id::get() { + Telemetry::set_firezone_id(id.id); + } + // Needed for the deep link server tauri::async_runtime::set(rt.handle().clone()); @@ -259,12 +294,16 @@ pub fn run( let (updates_tx, updates_rx) = mpsc::channel(1); - // Check for updates - tokio::spawn(async move { - if let Err(error) = updates::checker_task(updates_tx, config.debug_update_check).await { - tracing::error!("Error in updates::checker_task: {error:#}"); - } - }); + if mdm_settings.check_for_updates.is_none_or(|check| check) { + // Check for updates + tokio::spawn(async move { + if let Err(error) = updates::checker_task(updates_tx, config.debug_update_check).await { + tracing::error!("Error in updates::checker_task: {error:#}"); + } + }); + } else { + tracing::info!("Update checker disabled via MDM"); + } if config.smoke_test { let ctlr_tx = ctlr_tx.clone(); @@ -339,6 +378,7 @@ pub fn run( integration, ctlr_rx, general_settings, + mdm_settings, advanced_settings, reloader, updates_rx, diff --git a/rust/gui-client/src-tauri/src/gui/system_tray.rs b/rust/gui-client/src-tauri/src/gui/system_tray.rs index 2d15cc3b2..5acc73e61 100644 --- a/rust/gui-client/src-tauri/src/gui/system_tray.rs +++ b/rust/gui-client/src-tauri/src/gui/system_tray.rs @@ -249,6 +249,8 @@ fn build_item(app: &AppHandle, item: &Item) -> Result> { pub struct AppState { pub connlib: ConnlibState, pub release: Option, + pub hide_admin_portal_menu_item: bool, + pub support_url: Option, } impl Default for AppState { @@ -256,6 +258,8 @@ impl Default for AppState { AppState { connlib: ConnlibState::Loading, release: None, + hide_admin_portal_menu_item: false, + support_url: None, } } } @@ -282,7 +286,12 @@ impl AppState { ConnlibState::WaitingForPortal => signing_in("Connecting to Firezone Portal..."), ConnlibState::WaitingForTunnel => signing_in("Raising tunnel..."), }; - menu.add_bottom_section(self.release, quit_text) + menu.add_bottom_section( + self.release, + quit_text, + !self.hide_admin_portal_menu_item, + self.support_url, + ) } } @@ -481,7 +490,13 @@ fn append_status(name: &str, enabled: bool) -> String { impl Menu { /// Appends things that always show, like About, Settings, Help, Quit, etc. - pub(crate) fn add_bottom_section(mut self, release: Option, quit_text: &str) -> Self { + pub(crate) fn add_bottom_section( + mut self, + release: Option, + quit_text: &str, + show_admin_portal_url: bool, + support_url: Option, + ) -> Self { self = self.separator(); if let Some(release) = release { self = self.item( @@ -490,23 +505,29 @@ impl Menu { ) } - self.item(Event::ShowWindow(Window::About), "About Firezone") - .item(Event::AdminPortal, "Admin Portal...") - .add_submenu( - "Help", - Menu::default() - .item( - Event::Url(utm_url("https://www.firezone.dev/kb")), - "Documentation...", - ) - .item( - Event::Url(utm_url("https://www.firezone.dev/support")), - "Support...", + let mut item = self.item(Event::ShowWindow(Window::About), "About Firezone"); + + if show_admin_portal_url { + item = item.item(Event::AdminPortal, "Admin Portal..."); + } + + item.add_submenu( + "Help", + Menu::default() + .item( + Event::Url(utm_url("https://www.firezone.dev/kb")), + "Documentation...", + ) + .item( + Event::Url( + support_url.unwrap_or_else(|| utm_url("https://www.firezone.dev/support")), ), - ) - .item(Event::ShowWindow(Window::Settings), "Settings") - .separator() - .item(Event::Quit, quit_text) + "Support...", + ), + ) + .item(Event::ShowWindow(Window::Settings), "Settings") + .separator() + .item(Event::Quit, quit_text) } } @@ -551,6 +572,8 @@ mod tests { internet_resource_enabled, }), release: None, + hide_admin_portal_menu_item: false, + support_url: None, } } @@ -587,19 +610,89 @@ mod tests { serde_json::from_str(s).unwrap() } + #[test] + fn can_remove_admin_portal_link() { + let actual = AppState { + hide_admin_portal_menu_item: true, + ..Default::default() + } + .into_menu(); + + let expected = Menu::default() + .disabled("Loading...") + .separator() + .item(Event::ShowWindow(Window::About), "About Firezone") + .add_submenu( + "Help", + Menu::default() + .item( + Event::Url(utm_url("https://www.firezone.dev/kb")), + "Documentation...", + ) + .item( + Event::Url(utm_url("https://www.firezone.dev/support")), + "Support...", + ), + ) + .item(Event::ShowWindow(Window::Settings), "Settings") + .separator() + .item(Event::Quit, QUIT_TEXT_SIGNED_OUT); + + assert_eq!( + actual, + expected, + "{}", + serde_json::to_string_pretty(&actual).unwrap() + ); + } + + #[test] + fn can_change_support_url() { + let actual = AppState { + support_url: Some("https://example.com".parse().unwrap()), + ..Default::default() + } + .into_menu(); + + let expected = Menu::default() + .disabled("Loading...") + .separator() + .item(Event::ShowWindow(Window::About), "About Firezone") + .item(Event::AdminPortal, "Admin Portal...") + .add_submenu( + "Help", + Menu::default() + .item( + Event::Url(utm_url("https://www.firezone.dev/kb")), + "Documentation...", + ) + .item( + Event::Url("https://example.com".parse().unwrap()), + "Support...", + ), + ) + .item(Event::ShowWindow(Window::Settings), "Settings") + .separator() + .item(Event::Quit, QUIT_TEXT_SIGNED_OUT); + + assert_eq!( + actual, + expected, + "{}", + serde_json::to_string_pretty(&actual).unwrap() + ); + } + #[test] fn no_resources_no_favorites() { - let resources = vec![]; - let favorites = Default::default(); - let disabled_resources = Default::default(); - let input = signed_in(resources, favorites, disabled_resources); - let actual = input.into_menu(); + let actual = signed_in(vec![], HashSet::default(), None).into_menu(); + let expected = Menu::default() .disabled("Signed in as Jane Doe") .item(Event::SignOut, SIGN_OUT) .separator() .disabled(RESOURCES) - .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT, true, None); // Skip testing the bottom section, it's simple assert_eq!( actual, @@ -611,17 +704,15 @@ mod tests { #[test] fn no_resources_invalid_favorite() { - let resources = vec![]; - let favorites = HashSet::from([ResourceId::from_u128(42)]); - let disabled_resources = Default::default(); - let input = signed_in(resources, favorites, disabled_resources); - let actual = input.into_menu(); + let actual = + signed_in(vec![], HashSet::from([ResourceId::from_u128(42)]), None).into_menu(); + let expected = Menu::default() .disabled("Signed in as Jane Doe") .item(Event::SignOut, SIGN_OUT) .separator() .disabled(RESOURCES) - .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT, true, None); // Skip testing the bottom section, it's simple assert_eq!( actual, @@ -633,11 +724,8 @@ mod tests { #[test] fn some_resources_no_favorites() { - let resources = resources(); - let favorites = Default::default(); - let disabled_resources = Default::default(); - let input = signed_in(resources, favorites, disabled_resources); - let actual = input.into_menu(); + let actual = signed_in(resources(), HashSet::default(), None).into_menu(); + let expected = Menu::default() .disabled("Signed in as Jane Doe") .item(Event::SignOut, SIGN_OUT) @@ -697,7 +785,8 @@ mod tests { .copyable("test") .copyable(ALL_GATEWAYS_OFFLINE), ) - .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT, true, None); // Skip testing the bottom section, it's simple + assert_eq!( actual, expected, @@ -708,13 +797,15 @@ mod tests { #[test] fn some_resources_one_favorite() -> Result<()> { - let resources = resources(); - let favorites = HashSet::from([ResourceId::from_str( - "03000143-e25e-45c7-aafb-144990e57dcd", - )?]); - let disabled_resources = Default::default(); - let input = signed_in(resources, favorites, disabled_resources); - let actual = input.into_menu(); + let actual = signed_in( + resources(), + HashSet::from([ResourceId::from_str( + "03000143-e25e-45c7-aafb-144990e57dcd", + )?]), + None, + ) + .into_menu(); + let expected = Menu::default() .disabled("Signed in as Jane Doe") .item(Event::SignOut, SIGN_OUT) @@ -778,7 +869,7 @@ mod tests { .copyable(NO_ACTIVITY), ), ) - .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT, true, None); // Skip testing the bottom section, it's simple assert_eq!( actual, @@ -792,13 +883,15 @@ mod tests { #[test] fn some_resources_invalid_favorite() -> Result<()> { - let resources = resources(); - let favorites = HashSet::from([ResourceId::from_str( - "00000000-0000-0000-0000-000000000000", - )?]); - let disabled_resources = Default::default(); - let input = signed_in(resources, favorites, disabled_resources); - let actual = input.into_menu(); + let actual = signed_in( + resources(), + HashSet::from([ResourceId::from_str( + "00000000-0000-0000-0000-000000000000", + )?]), + None, + ) + .into_menu(); + let expected = Menu::default() .disabled("Signed in as Jane Doe") .item(Event::SignOut, SIGN_OUT) @@ -858,7 +951,7 @@ mod tests { .copyable("test") .copyable(ALL_GATEWAYS_OFFLINE), ) - .add_bottom_section(None, DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple + .add_bottom_section(None, DISCONNECT_AND_QUIT, true, None); // Skip testing the bottom section, it's simple assert_eq!( actual, diff --git a/rust/gui-client/src-tauri/src/settings.rs b/rust/gui-client/src-tauri/src/settings.rs index d9534a6e5..f0e64875a 100644 --- a/rust/gui-client/src-tauri/src/settings.rs +++ b/rust/gui-client/src-tauri/src/settings.rs @@ -13,6 +13,20 @@ use url::Url; use super::controller::ControllerRequest; +#[cfg(target_os = "linux")] +#[path = "settings/linux.rs"] +pub(crate) mod mdm; + +#[cfg(target_os = "windows")] +#[path = "settings/windows.rs"] +pub(crate) mod mdm; + +#[cfg(target_os = "macos")] +#[path = "settings/macos.rs"] +pub(crate) mod mdm; + +pub use mdm::load_mdm_settings; + #[tauri::command] pub(crate) async fn apply_advanced_settings( managed: tauri::State<'_, Managed>, @@ -40,6 +54,23 @@ pub(crate) async fn reset_advanced_settings( Ok(()) } +/// Defines all configuration options settable via MDM policies. +/// +/// Configuring Firezone via MDM is optional, therefore all of these are [`Option`]s. +/// Some of the policies can simply be enabled but don't have a value themselves. +/// Those are modelled as [`Option<()>`]. +#[derive(Clone, Default, Debug)] +pub struct MdmSettings { + pub auth_url: Option, + pub api_url: Option, + pub log_filter: Option, + pub account_slug: Option, + pub hide_admin_portal_menu_item: Option, + pub connect_on_start: Option, + pub check_for_updates: Option, + pub support_url: Option, +} + #[derive(Clone, Deserialize, Serialize)] pub struct AdvancedSettingsLegacy { pub auth_base_url: Url, @@ -66,6 +97,34 @@ pub struct GeneralSettings { pub internet_resource_enabled: Option, } +#[derive(Clone, Serialize)] +pub struct AdvancedSettingsViewModel { + pub auth_url: Url, + pub auth_url_is_managed: bool, + pub api_url: Url, + pub api_url_is_managed: bool, + pub log_filter: String, + pub log_filter_is_managed: bool, +} + +impl AdvancedSettingsViewModel { + pub fn new(mdm_settings: MdmSettings, advanced_settings: AdvancedSettings) -> Self { + Self { + auth_url_is_managed: mdm_settings.auth_url.is_some(), + api_url_is_managed: mdm_settings.api_url.is_some(), + log_filter_is_managed: mdm_settings.log_filter.is_some(), + + auth_url: mdm_settings + .auth_url + .unwrap_or(advanced_settings.auth_base_url), + api_url: mdm_settings.api_url.unwrap_or(advanced_settings.api_url), + log_filter: mdm_settings + .log_filter + .unwrap_or(advanced_settings.log_filter), + } + } +} + #[cfg(debug_assertions)] mod defaults { pub(crate) const AUTH_BASE_URL: &str = "https://app.firez.one"; diff --git a/rust/gui-client/src-tauri/src/settings/linux.rs b/rust/gui-client/src-tauri/src/settings/linux.rs new file mode 100644 index 000000000..8cf115c28 --- /dev/null +++ b/rust/gui-client/src-tauri/src/settings/linux.rs @@ -0,0 +1,6 @@ +use super::MdmSettings; +use anyhow::Result; + +pub fn load_mdm_settings() -> Result { + anyhow::bail!("Unimplemented") +} diff --git a/rust/gui-client/src-tauri/src/settings/macos.rs b/rust/gui-client/src-tauri/src/settings/macos.rs new file mode 100644 index 000000000..8cf115c28 --- /dev/null +++ b/rust/gui-client/src-tauri/src/settings/macos.rs @@ -0,0 +1,6 @@ +use super::MdmSettings; +use anyhow::Result; + +pub fn load_mdm_settings() -> Result { + anyhow::bail!("Unimplemented") +} diff --git a/rust/gui-client/src-tauri/src/settings/windows.rs b/rust/gui-client/src-tauri/src/settings/windows.rs new file mode 100644 index 000000000..2ece07863 --- /dev/null +++ b/rust/gui-client/src-tauri/src/settings/windows.rs @@ -0,0 +1,32 @@ +use super::MdmSettings; +use anyhow::Result; + +pub fn load_mdm_settings() -> Result { + let registry_values = MdmRegistryValues::load_from_registry()?; + + Ok(MdmSettings { + auth_url: registry_values.authURL.and_then(|url| url.parse().ok()), + api_url: registry_values.apiURL.and_then(|url| url.parse().ok()), + log_filter: registry_values.logFilter, + account_slug: registry_values.accountSlug, + hide_admin_portal_menu_item: registry_values.hideAdminPortalMenuItem, + connect_on_start: registry_values.connectOnStart, + check_for_updates: registry_values.checkForUpdates, + support_url: registry_values.supportURL.and_then(|url| url.parse().ok()), + }) +} + +/// Windows-specific struct for ADMX-backed MDM settings. +#[derive(Clone, Debug)] +#[admx_macro::admx(path = "../website/public/policy-templates/windows/firezone.admx")] +#[expect(non_snake_case, reason = "The values in the ADMX file are camel-case.")] +struct MdmRegistryValues { + authURL: Option, + apiURL: Option, + logFilter: Option, + accountSlug: Option, + hideAdminPortalMenuItem: Option, + connectOnStart: Option, + checkForUpdates: Option, + supportURL: Option, +} diff --git a/rust/gui-client/src/settings.html b/rust/gui-client/src/settings.html index 6ba6b08f7..f70bb2c05 100644 --- a/rust/gui-client/src/settings.html +++ b/rust/gui-client/src/settings.html @@ -63,7 +63,7 @@ @@ -77,7 +77,7 @@ @@ -91,7 +91,7 @@ diff --git a/rust/gui-client/src/settings.ts b/rust/gui-client/src/settings.ts index 36217fbff..7f68814d0 100644 --- a/rust/gui-client/src/settings.ts +++ b/rust/gui-client/src/settings.ts @@ -4,9 +4,12 @@ import "flowbite" // Custom types interface Settings { - auth_base_url: string; + auth_url: string; + auth_url_is_managed: boolean; api_url: string; + api_url_is_managed: boolean; log_filter: string; + log_filter_is_managed: boolean; } interface FileCount { @@ -168,7 +171,23 @@ logsTabBtn.addEventListener("click", (_e) => { listen('settings_changed', (e) => { let settings = e.payload; - authBaseUrlInput.value = settings.auth_base_url; + authBaseUrlInput.value = settings.auth_url; apiUrlInput.value = settings.api_url; logFilterInput.value = settings.log_filter; + + authBaseUrlInput.disabled = settings.auth_url_is_managed + apiUrlInput.disabled = settings.api_url_is_managed; + logFilterInput.disabled = settings.log_filter_is_managed; + + if (settings.auth_url_is_managed) { + authBaseUrlInput.dataset['tip'] = "This setting is managed by your organization." + } + + if (settings.api_url_is_managed) { + apiUrlInput.dataset['tip'] = "This setting is managed by your organization." + } + + if (settings.log_filter_is_managed) { + logFilterInput.dataset['tip'] = "This setting is managed by your organization." + } }) diff --git a/website/public/policy-templates/windows/README.md b/website/public/policy-templates/windows/README.md new file mode 100644 index 000000000..927343376 --- /dev/null +++ b/website/public/policy-templates/windows/README.md @@ -0,0 +1,7 @@ +# Windows policy templates + +These policy templates can be imported into Intune here: https://intune.microsoft.com/#view/Microsoft_Intune_DeviceSettings/DevicesWindowsMenu/~/configuration + +Intune only allows a single policy template per namespace to be active at any one time. +Therefore, in order to upload (and test) a new template, you need to delete the previous one. +The menu for deleting an uploaded ADMX file is hidden behind the three dots at the end of the row. diff --git a/website/public/policy-templates/windows/firezone.admx b/website/public/policy-templates/windows/firezone.admx new file mode 100644 index 000000000..5cd930ead --- /dev/null +++ b/website/public/policy-templates/windows/firezone.admx @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/public/policy-templates/windows/firezone_en-US.adml b/website/public/policy-templates/windows/firezone_en-US.adml new file mode 100644 index 000000000..d5aa8e233 --- /dev/null +++ b/website/public/policy-templates/windows/firezone_en-US.adml @@ -0,0 +1,74 @@ + + + + + + + Firezone + Authentication URL + WebSocket API URL + RUST_LOG filter string + Account Slug + Hide admin portal link + Connect on start + Automatically check for updates + Support URL + + + Firezone GUI Client 1.5.0 or later + + + + The base URL to open when users sign in. The accountSlug will be appended to this. In most cases you shouldn't change this. By default, the Client will use "https://app.firezone.dev". + + + The control plane WebSocket URL that the Tunnel service connects to. In most cases you shouldn't change this. By default, the Client will use "https://api.firezone.dev". + + + The RUST_LOG-style filter string to apply for increasing log output to use for connectivity troubleshooting. In most cases you shouldn't change this. By default, the Client will use "info". + + + Configures the account slug the Client will use to authenticate with Firezone. If this policy is set, the user will automatically be redirected to the login page for this account when signing into the Client. + + + Hide the Admin portal link in the Firezone menu in the taskbar. By default, the link to the admin portal is shown. + + + Try to connect to Firezone using the saved token and configuration when the client application starts. If the authentication token is expired, the client will start in a disconnected state. By default, the Client connects if it has a token saved in the keychain. + + + Configures whether the Firezone Client will automatically check for updates of new versions. By default, the Client will check for and notify the user of new versions. + + + The URL to which users will be taken to when clicking the Help -> Support link in the tray menu. By default, the Client will use "https://www.firezone.dev/support". + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/components/Changelog/GUI.tsx b/website/src/components/Changelog/GUI.tsx index ee6ce1e1b..4bb16c87a 100644 --- a/website/src/components/Changelog/GUI.tsx +++ b/website/src/components/Changelog/GUI.tsx @@ -3,6 +3,8 @@ import Entries, { DownloadLink } from "./Entries"; import ChangeItem from "./ChangeItem"; import Unreleased from "./Unreleased"; import { OS } from "."; +import Link from "next/link"; +import { Route } from "next"; export default function GUI({ os }: { os: OS }) { return ( @@ -10,9 +12,22 @@ export default function GUI({ os }: { os: OS }) { {/* When you cut a release, remove any solved issues from the "known issues" lists over in `client-apps`. This must not be done when the issue's PR merges. */} - Fixes an issue where changing the Advanced settings would reset - the favourited resources. + Fixes an issue where changing the Advanced settings would reset the + favourited resources. + {os === OS.Windows && ( + + Allows managing certain settings via an MDM provider such as + Microsoft Intune. For more details on how to do this, see the{" "} + + the knowledge base article + + . + + )}