feat(gui-client): add MDM config for Windows (#9203)

This PR adds the equivalent MDM configuration that we already have for
MacOS & iOS for the GUI client on Windows. These options are retrieved
from the Windows registry when the Client is started. Specifically, the
key for these is: `HKEY_CURRENT_USER\Software\Policies\Firezone`.

At moment, these cannot be configured or seen by the user. They are also
not "watched" for whilst the Client is running. If an admin pushes a new
MDM configuration, the Client will have to be restarted in order for
that new config to take effect.

Windows Policy templates are structured into two files:

- An `.admx` file that defines the structure of the policy, like the
kinds of values it has, where it is stored, which versions it is
supported on and which category it belongs to.
- An `.adml` file that defines defines all strings and presentation
logic, like the actual text of the policies and how the values are
presented in the GUI in e.g. Intune.

Internally, we differentiate between `MdmSettings` and
`AdvancedSettings`. The `MdmSettings` are cross-platform, however on
Linux, we always fallback to the defaults and therefore, they are always
"unset". Eventually, it might make sense to wrap both of these into a
more general `Settings` struct that acts as as a proxy for the two.

Related: #4505
This commit is contained in:
Thomas Eizinger
2025-05-27 11:33:51 +10:00
committed by GitHub
parent befc3b9eda
commit bed94a1d21
21 changed files with 868 additions and 105 deletions

17
rust/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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<proc_macro2::TokenStream> {
let admx_path = syn::parse::<AdmxPath>(attr)?;
let input = syn::parse::<ItemStruct>(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::<u32, _>(#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::<syn::Result<BTreeMap<_, _>>>()?;
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::<syn::Result<Vec<_>>>()?;
#[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<Self> {
Ok(Self {
#(#field_loads,)*
})
}
}
})
}
struct AdmxPath {
inner: LitStr,
span: Span,
}
impl Parse for AdmxPath {
fn parse(input: ParseStream) -> syn::Result<Self> {
let path = input.parse::<Path>()?;
input.parse::<Token![=]>()?;
let value = input.parse::<LitStr>()?;
if !path.is_ident("path") {
return Err(syn::Error::new(
path.span(),
r#"Expected a single key `path`: `#[admx(path = "<path to admx file>")]`"#,
));
}
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<Self, syn::Error> {
match s {
"text" => Ok(PolicyType::Text),
"decimal" => Ok(PolicyType::Decimal),
other => Err(syn::Error::new(
span,
format!("Unsupported ADMX policy type: {other}"),
)),
}
}
}

View File

@@ -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

View File

@@ -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(())
}

View File

@@ -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"
);
}

View File

@@ -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::<AdvancedSettingsLegacy>().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::<AdvancedSettingsLegacy>().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

View File

@@ -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<ControllerRequest>;
pub struct Controller<I: GuiIntegration> {
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<P: AsRef<str>>(&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<I: GuiIntegration> Controller<I> {
integration: I,
rx: mpsc::Receiver<ControllerRequest>,
general_settings: GeneralSettings,
mdm_settings: MdmSettings,
advanced_settings: AdvancedSettings,
log_filter_reloader: FilterReloadHandle,
updates_rx: mpsc::Receiver<Option<updates::Notification>>,
@@ -230,6 +240,7 @@ impl<I: GuiIntegration> Controller<I> {
let controller = Controller {
general_settings,
mdm_settings,
advanced_settings,
auth: auth::Auth::new()?,
clear_logs_callback: None,
@@ -266,12 +277,16 @@ impl<I: GuiIntegration> Controller<I> {
.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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
))?,
}
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<I: GuiIntegration> Controller<I> {
}
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<I: GuiIntegration> Controller<I> {
.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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
}
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
.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<impl Stream<Item = Result<()>>> {

View File

@@ -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,

View File

@@ -249,6 +249,8 @@ fn build_item(app: &AppHandle, item: &Item) -> Result<Box<IsMenuItem>> {
pub struct AppState {
pub connlib: ConnlibState,
pub release: Option<Release>,
pub hide_admin_portal_menu_item: bool,
pub support_url: Option<Url>,
}
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<Release>, quit_text: &str) -> Self {
pub(crate) fn add_bottom_section(
mut self,
release: Option<Release>,
quit_text: &str,
show_admin_portal_url: bool,
support_url: Option<Url>,
) -> 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,

View File

@@ -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<Url>,
pub api_url: Option<Url>,
pub log_filter: Option<String>,
pub account_slug: Option<String>,
pub hide_admin_portal_menu_item: Option<bool>,
pub connect_on_start: Option<bool>,
pub check_for_updates: Option<bool>,
pub support_url: Option<Url>,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct AdvancedSettingsLegacy {
pub auth_base_url: Url,
@@ -66,6 +97,34 @@ pub struct GeneralSettings {
pub internet_resource_enabled: Option<bool>,
}
#[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";

View File

@@ -0,0 +1,6 @@
use super::MdmSettings;
use anyhow::Result;
pub fn load_mdm_settings() -> Result<MdmSettings> {
anyhow::bail!("Unimplemented")
}

View File

@@ -0,0 +1,6 @@
use super::MdmSettings;
use anyhow::Result;
pub fn load_mdm_settings() -> Result<MdmSettings> {
anyhow::bail!("Unimplemented")
}

View File

@@ -0,0 +1,32 @@
use super::MdmSettings;
use anyhow::Result;
pub fn load_mdm_settings() -> Result<MdmSettings> {
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<String>,
apiURL: Option<String>,
logFilter: Option<String>,
accountSlug: Option<String>,
hideAdminPortalMenuItem: Option<bool>,
connectOnStart: Option<bool>,
checkForUpdates: Option<bool>,
supportURL: Option<String>,
}

View File

@@ -63,7 +63,7 @@
<input
name="auth-base-url"
id="auth-base-url-input"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer disabled:text-neutral-400 disabled:cursor-not-allowed"
placeholder=" "
required
/>
@@ -77,7 +77,7 @@
<input
name="api-url"
id="api-url-input"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer disabled:text-neutral-400 disabled:cursor-not-allowed"
placeholder=" "
required
/>
@@ -91,7 +91,7 @@
<input
name="log-filter"
id="log-filter-input"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer"
class="block py-2.5 px-0 w-full text-sm text-neutral-900 bg-transparent border-0 border-b-2 border-neutral-300 appearance-none focus:outline-hidden focus:ring-0 focus:border-accent-600 peer disabled:text-neutral-400 disabled:cursor-not-allowed"
placeholder=" "
required
/>

View File

@@ -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>('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."
}
})

View File

@@ -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.

View File

@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="utf-8" ?>
<policyDefinitions revision="1.0" schemaVersion="1.0">
<policyNamespaces>
<target namespace="Firezone.Policies" prefix="firezone" />
</policyNamespaces>
<resources minRequiredRevision="1.0" />
<supportedOn>
<definitions>
<definition
name="SUPPORTED_FZ_GUI_1_5_0"
displayName="$(string.SUPPORTED_FZ_GUI_1_5_0)"
/>
</definitions>
</supportedOn>
<categories>
<category displayName="$(string.firezone)" name="firezone" />
</categories>
<policies>
<policy
name="authURL"
class="User"
displayName="$(string.authURL)"
explainText="$(string.authURL_explain)"
key="Software\Policies\Firezone"
presentation="$(presentation.authURL)"
valueName="authURL"
>
<parentCategory ref="firezone" />
<supportedOn ref="SUPPORTED_FZ_GUI_1_5_0" />
<elements>
<text id="authURL" required="true" />
</elements>
</policy>
<policy
name="apiURL"
class="User"
displayName="$(string.apiURL)"
explainText="$(string.apiURL_explain)"
key="Software\Policies\Firezone"
presentation="$(presentation.apiURL)"
valueName="apiURL"
>
<parentCategory ref="firezone" />
<supportedOn ref="SUPPORTED_FZ_GUI_1_5_0" />
<elements>
<text id="apiURL" required="true" />
</elements>
</policy>
<policy
name="logFilter"
class="User"
displayName="$(string.logFilter)"
explainText="$(string.logFilter_explain)"
key="Software\Policies\Firezone"
presentation="$(presentation.logFilter)"
valueName="logFilter"
>
<parentCategory ref="firezone" />
<supportedOn ref="SUPPORTED_FZ_GUI_1_5_0" />
<elements>
<text id="logFilter" required="true" />
</elements>
</policy>
<policy
name="accountSlug"
class="User"
displayName="$(string.accountSlug)"
explainText="$(string.accountSlug_explain)"
key="Software\Policies\Firezone"
presentation="$(presentation.accountSlug)"
valueName="accountSlug"
>
<parentCategory ref="firezone" />
<supportedOn ref="SUPPORTED_FZ_GUI_1_5_0" />
<elements>
<text id="accountSlug" required="true" />
</elements>
</policy>
<policy
name="hideAdminPortalMenuItem"
class="User"
displayName="$(string.hideAdminPortalMenuItem)"
explainText="$(string.hideAdminPortalMenuItem_explain)"
key="Software\Policies\Firezone"
valueName="hideAdminPortalMenuItem"
>
<parentCategory ref="firezone" />
<supportedOn ref="SUPPORTED_FZ_GUI_1_5_0" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
<policy
name="connectOnStart"
class="User"
displayName="$(string.connectOnStart)"
explainText="$(string.connectOnStart_explain)"
key="Software\Policies\Firezone"
valueName="connectOnStart"
>
<parentCategory ref="firezone" />
<supportedOn ref="SUPPORTED_FZ_GUI_1_5_0" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
<policy
name="checkForUpdates"
class="User"
displayName="$(string.checkForUpdates)"
explainText="$(string.checkForUpdates_explain)"
key="Software\Policies\Firezone"
valueName="checkForUpdates"
>
<parentCategory ref="firezone" />
<supportedOn ref="SUPPORTED_FZ_GUI_1_5_0" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
<policy
name="supportURL"
class="User"
displayName="$(string.supportURL)"
explainText="$(string.supportURL_explain)"
key="Software\Policies\Firezone"
presentation="$(presentation.supportURL)"
valueName="supportURL"
>
<parentCategory ref="firezone" />
<supportedOn ref="SUPPORTED_FZ_GUI_1_5_0" />
<elements>
<text id="supportURL" required="true" />
</elements>
</policy>
</policies>
</policyDefinitions>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8" ?>
<policyDefinitionResources revision="1.0" schemaVersion="1.0">
<displayName />
<description />
<resources>
<stringTable>
<string id="firezone">Firezone</string>
<string id="authURL">Authentication URL</string>
<string id="apiURL">WebSocket API URL</string>
<string id="logFilter">RUST_LOG filter string</string>
<string id="accountSlug">Account Slug</string>
<string id="hideAdminPortalMenuItem">Hide admin portal link</string>
<string id="connectOnStart">Connect on start</string>
<string id="checkForUpdates">Automatically check for updates</string>
<string id="supportURL">Support URL</string>
<string id="SUPPORTED_FZ_GUI_1_5_0">
Firezone GUI Client 1.5.0 or later
</string>
<string id="authURL_explain">
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".
</string>
<string id="apiURL_explain">
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".
</string>
<string id="logFilter_explain">
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".
</string>
<string id="accountSlug_explain">
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.
</string>
<string id="hideAdminPortalMenuItem_explain">
Hide the Admin portal link in the Firezone menu in the taskbar. By default, the link to the admin portal is shown.
</string>
<string id="connectOnStart_explain">
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.
</string>
<string id="checkForUpdates_explain">
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.
</string>
<string id="supportURL_explain">
The URL to which users will be taken to when clicking the Help -&gt; Support link in the tray menu. By default, the Client will use "https://www.firezone.dev/support".
</string>
</stringTable>
<presentationTable>
<presentation id="authURL">
<textBox refId="authURL">
<label>URL:</label>
</textBox>
</presentation>
<presentation id="apiURL">
<textBox refId="apiURL">
<label>URL:</label>
</textBox>
</presentation>
<presentation id="logFilter">
<textBox refId="logFilter">
<label>Log filter:</label>
</textBox>
</presentation>
<presentation id="accountSlug">
<textBox refId="accountSlug">
<label>Account slug:</label>
</textBox>
</presentation>
<presentation id="supportURL">
<textBox refId="supportURL">
<label>URL:</label>
</textBox>
</presentation>
</presentationTable>
</resources>
</policyDefinitionResources>

View File

@@ -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. */}
<Unreleased>
<ChangeItem pull="9211">
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.
</ChangeItem>
{os === OS.Windows && (
<ChangeItem pull="9203">
Allows managing certain settings via an MDM provider such as
Microsoft Intune. For more details on how to do this, see the{" "}
<Link
href={"/kb/deploy/clients#provision-with-mdm" as Route}
className="text-accent-500 underline hover:no-underline"
>
the knowledge base article
</Link>
.
</ChangeItem>
)}
</Unreleased>
<Entry version="1.4.14" date={new Date("2025-05-21")}>
<ChangeItem pull="9147">