From b338cc8706f726dd432264e085bf898b38049d7b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 20 Nov 2025 17:12:12 +0100 Subject: [PATCH 01/17] Merge pull request #1706 from DefGuard/all-traffic-only Related issue: https://github.com/DefGuard/defguard/issues/880 Adds "force all traffic" option to enterprise settings. When selected, all clients are forced to route all traffic via the vpn. --- ...ba68521649ba86985d45049487ae50d7dfde8.json | 43 +++++++++ ...b715db1c1df67da573b0f132fea265e42b416.json | 32 ------- ...b3dbb0fc747ae0843baa001e47ea328e49c25.json | 27 ++++++ ...a5e7829eae217ad9f7f3b0b03aa02f8808dc2.json | 16 ---- .../db/models/enterprise_settings.rs | 33 +++++-- crates/defguard_core/src/grpc/mod.rs | 15 ++- .../integration/api/enterprise_settings.rs | 12 +-- ...51119122424_client_traffic_policy.down.sql | 13 +++ ...0251119122424_client_traffic_policy.up.sql | 19 ++++ proto | 2 +- web/src/i18n/en/index.ts | 23 ++++- web/src/i18n/i18n-types.ts | 92 +++++++++++++++---- web/src/i18n/pl/index.ts | 23 ++++- .../components/EnterpriseForm.tsx | 14 +-- .../TrafficPolicySelect.tsx | 77 ++++++++++++++++ .../components/TrafficPolicySelect/style.scss | 59 ++++++++++++ web/src/shared/types.ts | 8 +- 17 files changed, 399 insertions(+), 109 deletions(-) create mode 100644 .sqlx/query-160d23b882d0465fbc8c5453b7dba68521649ba86985d45049487ae50d7dfde8.json delete mode 100644 .sqlx/query-283e1c3d082f1388fc2b806bdcab715db1c1df67da573b0f132fea265e42b416.json create mode 100644 .sqlx/query-a644507ebcfb9ef04883ad8b07bb3dbb0fc747ae0843baa001e47ea328e49c25.json delete mode 100644 .sqlx/query-ccd62ea7526078c9db47812e7f6a5e7829eae217ad9f7f3b0b03aa02f8808dc2.json create mode 100644 migrations/20251119122424_client_traffic_policy.down.sql create mode 100644 migrations/20251119122424_client_traffic_policy.up.sql create mode 100644 web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/TrafficPolicySelect.tsx create mode 100644 web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss diff --git a/.sqlx/query-160d23b882d0465fbc8c5453b7dba68521649ba86985d45049487ae50d7dfde8.json b/.sqlx/query-160d23b882d0465fbc8c5453b7dba68521649ba86985d45049487ae50d7dfde8.json new file mode 100644 index 000000000..3b3177056 --- /dev/null +++ b/.sqlx/query-160d23b882d0465fbc8c5453b7dba68521649ba86985d45049487ae50d7dfde8.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT admin_device_management, client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\", only_client_activation FROM \"enterprisesettings\" WHERE id = 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "admin_device_management", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "client_traffic_policy: ClientTrafficPolicy", + "type_info": { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "only_client_activation", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "160d23b882d0465fbc8c5453b7dba68521649ba86985d45049487ae50d7dfde8" +} diff --git a/.sqlx/query-283e1c3d082f1388fc2b806bdcab715db1c1df67da573b0f132fea265e42b416.json b/.sqlx/query-283e1c3d082f1388fc2b806bdcab715db1c1df67da573b0f132fea265e42b416.json deleted file mode 100644 index c1696c5b0..000000000 --- a/.sqlx/query-283e1c3d082f1388fc2b806bdcab715db1c1df67da573b0f132fea265e42b416.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT admin_device_management, disable_all_traffic, only_client_activation FROM \"enterprisesettings\" WHERE id = 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "admin_device_management", - "type_info": "Bool" - }, - { - "ordinal": 1, - "name": "disable_all_traffic", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "only_client_activation", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "283e1c3d082f1388fc2b806bdcab715db1c1df67da573b0f132fea265e42b416" -} diff --git a/.sqlx/query-a644507ebcfb9ef04883ad8b07bb3dbb0fc747ae0843baa001e47ea328e49c25.json b/.sqlx/query-a644507ebcfb9ef04883ad8b07bb3dbb0fc747ae0843baa001e47ea328e49c25.json new file mode 100644 index 000000000..509af4943 --- /dev/null +++ b/.sqlx/query-a644507ebcfb9ef04883ad8b07bb3dbb0fc747ae0843baa001e47ea328e49c25.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"enterprisesettings\" SET admin_device_management = $1, client_traffic_policy = $2, only_client_activation = $3 WHERE id = 1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + { + "Custom": { + "name": "client_traffic_policy", + "kind": { + "Enum": [ + "none", + "disable_all_traffic", + "force_all_traffic" + ] + } + } + }, + "Bool" + ] + }, + "nullable": [] + }, + "hash": "a644507ebcfb9ef04883ad8b07bb3dbb0fc747ae0843baa001e47ea328e49c25" +} diff --git a/.sqlx/query-ccd62ea7526078c9db47812e7f6a5e7829eae217ad9f7f3b0b03aa02f8808dc2.json b/.sqlx/query-ccd62ea7526078c9db47812e7f6a5e7829eae217ad9f7f3b0b03aa02f8808dc2.json deleted file mode 100644 index 787a75d7d..000000000 --- a/.sqlx/query-ccd62ea7526078c9db47812e7f6a5e7829eae217ad9f7f3b0b03aa02f8808dc2.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE \"enterprisesettings\" SET admin_device_management = $1, disable_all_traffic = $2, only_client_activation = $3 WHERE id = 1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Bool", - "Bool", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "ccd62ea7526078c9db47812e7f6a5e7829eae217ad9f7f3b0b03aa02f8808dc2" -} diff --git a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs index f2e85ced8..d1c9be350 100644 --- a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs @@ -1,4 +1,4 @@ -use sqlx::{PgExecutor, query, query_as}; +use sqlx::{PgExecutor, Type, query, query_as}; use struct_patch::Patch; use crate::enterprise::is_enterprise_enabled; @@ -6,11 +6,11 @@ use crate::enterprise::is_enterprise_enabled; #[derive(Debug, Deserialize, Patch, Serialize)] #[patch(attribute(derive(Deserialize, Serialize)))] pub struct EnterpriseSettings { - // If true, only admins can manage devices + /// If true, only admins can manage devices pub admin_device_management: bool, - // If true, the option to route all traffic through the vpn is disabled in the client - pub disable_all_traffic: bool, - // If true, manual WireGuard setup is disabled + /// Describes allowed routing options for clients connecting to the instance. + pub client_traffic_policy: ClientTrafficPolicy, + /// If true, manual WireGuard setup is disabled pub only_client_activation: bool, } @@ -20,8 +20,8 @@ impl Default for EnterpriseSettings { fn default() -> Self { Self { admin_device_management: false, - disable_all_traffic: false, only_client_activation: false, + client_traffic_policy: ClientTrafficPolicy::default(), } } } @@ -39,7 +39,8 @@ impl EnterpriseSettings { let settings = query_as!( Self, "SELECT admin_device_management, \ - disable_all_traffic, only_client_activation \ + client_traffic_policy \"client_traffic_policy: ClientTrafficPolicy\", \ + only_client_activation \ FROM \"enterprisesettings\" WHERE id = 1", ) .fetch_optional(executor) @@ -57,11 +58,11 @@ impl EnterpriseSettings { query!( "UPDATE \"enterprisesettings\" SET \ admin_device_management = $1, \ - disable_all_traffic = $2, \ + client_traffic_policy = $2, \ only_client_activation = $3 \ WHERE id = 1", self.admin_device_management, - self.disable_all_traffic, + self.client_traffic_policy as ClientTrafficPolicy, self.only_client_activation, ) .execute(executor) @@ -70,3 +71,17 @@ impl EnterpriseSettings { Ok(()) } } + +/// Describes allowed traffic options for clients connecting to the instance. +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Type, Debug, Default, Copy)] +#[sqlx(type_name = "client_traffic_policy", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum ClientTrafficPolicy { + /// No restrictions + #[default] + None, + /// Clients are not allowed to route all traffic through the VPN. + DisableAllTraffic, + /// Clients are forced to route all traffic through the VPN. + ForceAllTraffic, +} diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index 96dbd24e6..a4c4ba3dc 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -50,7 +50,10 @@ use crate::{ models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token}, }, enterprise::{ - db::models::{enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider}, + db::models::{ + enterprise_settings::{ClientTrafficPolicy, EnterpriseSettings}, + openid_provider::OpenIdProvider, + }, directory_sync::sync_user_groups_if_configured, grpc::polling::PollingServer, handlers::openid_login::{ @@ -806,7 +809,7 @@ pub struct InstanceInfo { url: Url, proxy_url: Url, username: String, - disable_all_traffic: bool, + client_traffic_policy: ClientTrafficPolicy, enterprise_enabled: bool, openid_display_name: Option, } @@ -829,7 +832,7 @@ impl InstanceInfo { url: config.url.clone(), proxy_url: config.enrollment_url.clone(), username: username.into(), - disable_all_traffic: enterprise_settings.disable_all_traffic, + client_traffic_policy: enterprise_settings.client_traffic_policy, enterprise_enabled: is_enterprise_enabled(), openid_display_name, } @@ -844,7 +847,11 @@ impl From for defguard_proto::proxy::InstanceInfo { url: instance.url.to_string(), proxy_url: instance.proxy_url.to_string(), username: instance.username, - disable_all_traffic: instance.disable_all_traffic, + // Ensure backwards compatibility. + #[allow(deprecated)] + disable_all_traffic: instance.client_traffic_policy + == ClientTrafficPolicy::DisableAllTraffic, + client_traffic_policy: Some(instance.client_traffic_policy as i32), enterprise_enabled: instance.enterprise_enabled, openid_display_name: instance.openid_display_name, } diff --git a/crates/defguard_core/tests/integration/api/enterprise_settings.rs b/crates/defguard_core/tests/integration/api/enterprise_settings.rs index f24582a65..c065dde6c 100644 --- a/crates/defguard_core/tests/integration/api/enterprise_settings.rs +++ b/crates/defguard_core/tests/integration/api/enterprise_settings.rs @@ -1,6 +1,6 @@ use defguard_core::{ enterprise::{ - db::models::enterprise_settings::EnterpriseSettings, + db::models::enterprise_settings::{ClientTrafficPolicy, EnterpriseSettings}, license::{get_cached_license, set_cached_license}, }, handlers::Auth, @@ -33,7 +33,7 @@ async fn test_only_enterprise_can_modify_enterpise_settings( // try to patch enterprise settings let settings = EnterpriseSettings { admin_device_management: false, - disable_all_traffic: false, + client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: false, }; @@ -81,7 +81,7 @@ async fn test_admin_devices_management_is_enforced(_: PgPoolOptions, options: Pg // setup admin devices management let settings = EnterpriseSettings { admin_device_management: true, - disable_all_traffic: false, + client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: false, }; let response = client @@ -177,7 +177,7 @@ async fn test_regular_user_device_management(_: PgPoolOptions, options: PgConnec // setup admin devices management let settings = EnterpriseSettings { admin_device_management: false, - disable_all_traffic: false, + client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: false, }; let response = client @@ -265,7 +265,7 @@ async fn dg25_12_test_enforce_client_activation_only(_: PgPoolOptions, options: // disable manual device management let settings = EnterpriseSettings { admin_device_management: false, - disable_all_traffic: false, + client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: true, }; let response = client @@ -346,7 +346,7 @@ async fn dg25_13_test_disable_device_config(_: PgPoolOptions, options: PgConnect // disable manual device management let settings = EnterpriseSettings { admin_device_management: false, - disable_all_traffic: false, + client_traffic_policy: ClientTrafficPolicy::None, only_client_activation: true, }; let response = client diff --git a/migrations/20251119122424_client_traffic_policy.down.sql b/migrations/20251119122424_client_traffic_policy.down.sql new file mode 100644 index 000000000..db730678d --- /dev/null +++ b/migrations/20251119122424_client_traffic_policy.down.sql @@ -0,0 +1,13 @@ +-- restore boolean `mfa_enabled` column +ALTER TABLE enterprisesettings ADD COLUMN "disable_all_traffic" BOOLEAN NOT NULL DEFAULT false; + +-- populate based on client traffic policy +UPDATE enterprisesettings +SET disable_all_traffic = CASE + WHEN client_traffic_policy = 'disable_all_traffic'::client_traffic_policy THEN true + ELSE false +END; + +-- drop new column and type +ALTER TABLE enterprisesettings DROP COLUMN "client_traffic_policy"; +DROP TYPE client_traffic_policy; diff --git a/migrations/20251119122424_client_traffic_policy.up.sql b/migrations/20251119122424_client_traffic_policy.up.sql new file mode 100644 index 000000000..a5bfb8d8a --- /dev/null +++ b/migrations/20251119122424_client_traffic_policy.up.sql @@ -0,0 +1,19 @@ +-- add enum representing client traffic policy +CREATE TYPE client_traffic_policy AS ENUM ( + 'none', + 'disable_all_traffic', + 'force_all_traffic' +); + +-- add column to `enterprisesettings` table +ALTER TABLE enterprisesettings ADD COLUMN "client_traffic_policy" client_traffic_policy NOT NULL DEFAULT 'none'; + +-- populate new column based on value in `disable_all_traffic` column +UPDATE enterprisesettings +SET client_traffic_policy = CASE + WHEN disable_all_traffic = true THEN 'disable_all_traffic'::client_traffic_policy + ELSE 'none'::client_traffic_policy +END; + +-- drop the `disable_all_traffic` column since it's no longer needed +ALTER TABLE enterprisesettings DROP COLUMN "disable_all_traffic"; diff --git a/proto b/proto index 96249ebde..74d60d917 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 96249ebde0556f4ae8c47eebc6015efb04ed0104 +Subproject commit 74d60d9171048ba0ccaf8a21b05950fb7a673f09 diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index f4b94015e..ce572bb6b 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -1708,16 +1708,29 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do helper: "When this option is enabled, only users in the Admin group can manage devices in user profile (it's disabled for all other users)", }, - disableAllTraffic: { - label: 'Disable the option to route all traffic through VPN', - helper: - 'When this option is enabled, users will not be able to route all traffic through the VPN using the defguard client.', - }, manualConfig: { label: "Disable users' ability to manually configure WireGuard client", helper: "When this option is enabled, users won't be able to view or download configuration for the manual WireGuard client setup. Only the Defguard desktop client configuration will be available.", }, + clientTrafficPolicy: { + header: 'Client traffic policy', + none: { + label: 'None', + helper: + 'When this option is enabled, users will be able to select all routing options.', + }, + disableAllTraffic: { + label: 'Disable the option to route all traffic through VPN', + helper: + 'When this option is enabled, users will not be able to route all traffic through the VPN.', + }, + forceAllTraffic: { + label: 'Force the clients to route all traffic through VPN', + helper: + 'When this option is enabled, the users will always route all traffic through the VPN.', + }, + }, }, }, gatewayNotifications: { diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 2313f93c1..517c50734 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -4082,16 +4082,6 @@ type RootTranslation = { */ helper: string } - disableAllTraffic: { - /** - * D​i​s​a​b​l​e​ ​t​h​e​ ​o​p​t​i​o​n​ ​t​o​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​V​P​N - */ - label: string - /** - * W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​u​s​e​r​s​ ​w​i​l​l​ ​n​o​t​ ​b​e​ ​a​b​l​e​ ​t​o​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​V​P​N​ ​u​s​i​n​g​ ​t​h​e​ ​d​e​f​g​u​a​r​d​ ​c​l​i​e​n​t​. - */ - helper: string - } manualConfig: { /** * D​i​s​a​b​l​e​ ​u​s​e​r​s​'​ ​a​b​i​l​i​t​y​ ​t​o​ ​m​a​n​u​a​l​l​y​ ​c​o​n​f​i​g​u​r​e​ ​W​i​r​e​G​u​a​r​d​ ​c​l​i​e​n​t @@ -4102,6 +4092,42 @@ type RootTranslation = { */ helper: string } + clientTrafficPolicy: { + /** + * C​l​i​e​n​t​ ​t​r​a​f​f​i​c​ ​p​o​l​i​c​y + */ + header: string + none: { + /** + * N​o​n​e + */ + label: string + /** + * W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​u​s​e​r​s​ ​w​i​l​l​ ​b​e​ ​a​b​l​e​ ​t​o​ ​s​e​l​e​c​t​ ​a​l​l​ ​r​o​u​t​i​n​g​ ​o​p​t​i​o​n​s​. + */ + helper: string + } + disableAllTraffic: { + /** + * D​i​s​a​b​l​e​ ​t​h​e​ ​o​p​t​i​o​n​ ​t​o​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​V​P​N + */ + label: string + /** + * W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​u​s​e​r​s​ ​w​i​l​l​ ​n​o​t​ ​b​e​ ​a​b​l​e​ ​t​o​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​V​P​N​. + */ + helper: string + } + forceAllTraffic: { + /** + * F​o​r​c​e​ ​t​h​e​ ​c​l​i​e​n​t​s​ ​t​o​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​V​P​N + */ + label: string + /** + * W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​t​h​e​ ​u​s​e​r​s​ ​w​i​l​l​ ​a​l​w​a​y​s​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​V​P​N​. + */ + helper: string + } + } } } gatewayNotifications: { @@ -10813,16 +10839,6 @@ export type TranslationFunctions = { */ helper: () => LocalizedString } - disableAllTraffic: { - /** - * Disable the option to route all traffic through VPN - */ - label: () => LocalizedString - /** - * When this option is enabled, users will not be able to route all traffic through the VPN using the defguard client. - */ - helper: () => LocalizedString - } manualConfig: { /** * Disable users' ability to manually configure WireGuard client @@ -10833,6 +10849,42 @@ export type TranslationFunctions = { */ helper: () => LocalizedString } + clientTrafficPolicy: { + /** + * Client traffic policy + */ + header: () => LocalizedString + none: { + /** + * None + */ + label: () => LocalizedString + /** + * When this option is enabled, users will be able to select all routing options. + */ + helper: () => LocalizedString + } + disableAllTraffic: { + /** + * Disable the option to route all traffic through VPN + */ + label: () => LocalizedString + /** + * When this option is enabled, users will not be able to route all traffic through the VPN. + */ + helper: () => LocalizedString + } + forceAllTraffic: { + /** + * Force the clients to route all traffic through VPN + */ + label: () => LocalizedString + /** + * When this option is enabled, the users will always route all traffic through the VPN. + */ + helper: () => LocalizedString + } + } } } gatewayNotifications: { diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 652590151..7356101f6 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -1494,16 +1494,29 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe helper: 'Kiedy ta opcja jest włączona, tylko użytkownicy w grupie "Admin" mogą zarządzać urządzeniami w profilu użytkownika', }, - disableAllTraffic: { - label: 'Zablokuj możliwość przekierowania całego ruchu przez VPN', - helper: - 'Kiedy ta opcja jest włączona, użytkownicy nie będą mogli przekierować całego ruchu przez VPN za pomocą klienta Defguard.', - }, manualConfig: { label: 'Wyłącz manualną konfigurację WireGuard', helper: 'Kiedy ta opcja jest włączona, użytkownicy nie będą mogli pobrać ani wyświetlić danych do manualnej konfiguracji WireGuard. Możliwe będzie wyłącznie skonfigurowanie klienta Defguard.', }, + clientTrafficPolicy: { + header: 'Polityka przekierowania ruchu klientów', + none: { + label: 'Brak', + helper: + 'Kiedy ta opcja jest włączona, użytkownicy mogą wybierać dowolny typ przekierowania ruchu.', + }, + disableAllTraffic: { + label: 'Zablokuj możliwość przekierowania całego ruchu przez VPN', + helper: + 'Kiedy ta opcja jest włączona, użytkownicy nie będą mogli przekierować całego ruchu przez VPN.', + }, + forceAllTraffic: { + label: 'Wymuś przekierowanie całego ruchu przez VPN', + helper: + 'Kiedy ta opcja jest włączona, użytkownicy będą zawsze przekierowywać cały ruch przez VPN.', + }, + } }, }, gatewayNotifications: { diff --git a/web/src/pages/settings/components/EnterpriseSettings/components/EnterpriseForm.tsx b/web/src/pages/settings/components/EnterpriseSettings/components/EnterpriseForm.tsx index d7df932de..c8bcdc885 100644 --- a/web/src/pages/settings/components/EnterpriseSettings/components/EnterpriseForm.tsx +++ b/web/src/pages/settings/components/EnterpriseSettings/components/EnterpriseForm.tsx @@ -12,6 +12,7 @@ import useApi from '../../../../../shared/hooks/useApi'; import { useToaster } from '../../../../../shared/hooks/useToaster'; import { MutationKeys } from '../../../../../shared/mutations'; import { QueryKeys } from '../../../../../shared/queries'; +import { ClientTrafficPolicySelect } from './TrafficPolicySelect/TrafficPolicySelect'; export const EnterpriseForm = () => { const { LL } = useI18nContext(); @@ -77,17 +78,10 @@ export const EnterpriseForm = () => {
- - mutate({ disable_all_traffic: !settings.disable_all_traffic }) - } + mutate({ client_traffic_policy: value })} + fieldValue={settings.client_traffic_policy} /> - - {parse(LL.settingsPage.enterprise.fields.disableAllTraffic.helper())} -
diff --git a/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/TrafficPolicySelect.tsx b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/TrafficPolicySelect.tsx new file mode 100644 index 000000000..43fa5f3f9 --- /dev/null +++ b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/TrafficPolicySelect.tsx @@ -0,0 +1,77 @@ +import './style.scss'; +import clsx from 'clsx'; +import parse from 'html-react-parser'; +import { useMemo } from 'react'; +import { useI18nContext } from '../../../../../../i18n/i18n-react'; +import { Helper } from '../../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; +import { RadioButton } from '../../../../../../shared/defguard-ui/components/Layout/RadioButton/Radiobutton'; +import type { SelectOption } from '../../../../../../shared/defguard-ui/components/Layout/Select/types'; +import { ClientTrafficPolicy } from '../../../../../../shared/types'; + +type Props = { + onChange: (event: ClientTrafficPolicy) => void; + fieldValue: ClientTrafficPolicy; +}; + +export const ClientTrafficPolicySelect = ({ onChange, fieldValue }: Props) => { + const { LL } = useI18nContext(); + const options = useMemo( + (): SelectOption[] => [ + { + key: ClientTrafficPolicy.NONE, + value: ClientTrafficPolicy.NONE, + label: LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.label(), + meta: LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.helper(), + }, + { + key: ClientTrafficPolicy.DISABLE_ALL_TRAFFIC, + value: ClientTrafficPolicy.DISABLE_ALL_TRAFFIC, + label: + LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.label(), + meta: LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.helper(), + }, + { + key: ClientTrafficPolicy.FORCE_ALL_TRAFFIC, + value: ClientTrafficPolicy.FORCE_ALL_TRAFFIC, + label: + LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.label(), + meta: LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.helper(), + }, + ], + [ + LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.label, + LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.helper, + LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.label, + LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.helper, + LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.label, + LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.helper, + ], + ); + + return ( +
+ + {options.map(({ key, value, label, meta, disabled = false }) => { + const active = fieldValue === value; + return ( +
{ + if (!disabled) { + onChange(value); + } + }} + > +

{label}

+ + {parse(meta)} +
+ ); + })} +
+ ); +}; diff --git a/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss new file mode 100644 index 000000000..692d1123b --- /dev/null +++ b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss @@ -0,0 +1,59 @@ +.client-traffic-policy-select { + display: flex; + flex-flow: column; + row-gap: var(--spacing-s); + margin-bottom: 25px; + + .client-traffic-policy { + display: flex; + align-items: center; + justify-content: space-between; + column-gap: var(--spacing-xs); + min-height: 30px; + border: 1px solid var(--border-primary); + padding: var(--spacing-xs) var(--spacing-s); + border-radius: 10px; + cursor: pointer; + user-select: none; + transition-property: border-color, opacity; + @include animate-standard; + + &:not(.active) { + &:hover { + border-color: var(--border-separator); + } + } + + &.active { + border-color: var(--surface-main-primary); + } + + &.active, + &:hover { + .label { + color: var(--text-body-primary); + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: var(--surface-secondary); + + .label { + color: var(--text-body-disabled); + } + + &:hover { + border-color: var(--border-primary); + } + } + + .label { + color: var(--text-body-secondary); + transition-property: color; + @include typography(app-modal-1); + @include animate-standard; + } + } +} diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 3c181cb11..33258f66b 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -1121,9 +1121,15 @@ export type SettingsGatewayNotifications = { gateway_disconnect_notifications_reconnect_notification_enabled: boolean; }; +export enum ClientTrafficPolicy { + NONE = 'none', + DISABLE_ALL_TRAFFIC = 'disable_all_traffic', + FORCE_ALL_TRAFFIC = 'force_all_traffic', +} + export type SettingsEnterprise = { admin_device_management: boolean; - disable_all_traffic: boolean; + client_traffic_policy: ClientTrafficPolicy; only_client_activation: boolean; }; From 0659ae1b9fcd5a9d3e03a5c9c7c7f572e86955af Mon Sep 17 00:00:00 2001 From: jakub-tldr <78603704+jakub-tldr@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:18:35 +0100 Subject: [PATCH 02/17] Filter MFA locations on network devices modal, block creating devices without name (#1719) * filter mfa locations, validate ip/domain in wizard * Reject device without name --- crates/defguard_core/src/grpc/enrollment.rs | 5 +++++ .../steps/MethodStep/MethodStep.tsx | 12 +++++++----- .../WizardNetworkConfiguration.tsx | 5 ++++- web/src/shared/validators.ts | 18 ++++++++++++------ 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/defguard_core/src/grpc/enrollment.rs b/crates/defguard_core/src/grpc/enrollment.rs index c7a43069b..13b0733ea 100644 --- a/crates/defguard_core/src/grpc/enrollment.rs +++ b/crates/defguard_core/src/grpc/enrollment.rs @@ -695,6 +695,11 @@ impl EnrollmentServer { None, true, ); + if device.name.is_empty() { + return Err(Status::invalid_argument( + "Cannot add a new device with no name. You may be trying to add a new user device as a network device. Defguard CLI supports only network devices.", + )); + } let device = device.save(&mut *transaction).await.map_err(|err| { error!( "Failed to save device {}, pubkey {} for user {}({:?}): {err}", diff --git a/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/MethodStep/MethodStep.tsx b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/MethodStep/MethodStep.tsx index a054a9150..ef6b9f085 100644 --- a/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/MethodStep/MethodStep.tsx +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/MethodStep/MethodStep.tsx @@ -88,11 +88,13 @@ export const MethodStep = () => { // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { if (networks) { - const options: SelectOption[] = networks.map((n) => ({ - key: n.id, - value: n.id, - label: n.name, - })); + const options: SelectOption[] = networks + .filter((n) => n.location_mfa_mode === 'disabled') + .map((n) => ({ + key: n.id, + value: n.id, + label: n.name, + })); setState({ networks, networkOptions: options, diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx index 8d416e221..6ecdd2034 100644 --- a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx +++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx @@ -118,7 +118,10 @@ export const WizardNetworkConfiguration = () => { .string() .trim() .min(1, LL.form.error.required()) - .refine((val) => validateIpOrDomain(val), LL.form.error.endpoint()), + .refine( + (val) => validateIpOrDomain(val, false, true), + LL.form.error.endpoint(), + ), port: z .number({ invalid_type_error: LL.form.error.invalid(), diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts index 77df62d88..8d9966400 100644 --- a/web/src/shared/validators.ts +++ b/web/src/shared/validators.ts @@ -1,6 +1,5 @@ import ipaddr from 'ipaddr.js'; import { z } from 'zod'; - import { patternValidDomain, patternValidWireguardKey } from './patterns'; export const validateWireguardPublicKey = (props: { @@ -24,11 +23,13 @@ export const validateIpOrDomain = ( allowMask = false, allowIPv6 = false, ): boolean => { - return ( - (allowIPv6 && validateIPv6(val, allowMask)) || - validateIPv4(val, allowMask) || - patternValidDomain.test(val) - ); + const hasLetter = /\p{L}/u.test(val); + const hasColon = /:/.test(val); + if (!hasLetter || hasColon) { + return (allowIPv6 && validateIPv6(val, allowMask)) || validateIPv4(val, allowMask); + } else { + return patternValidDomain.test(val); + } }; // Returns false when invalid @@ -41,6 +42,7 @@ export const validateIpList = ( .replace(' ', '') .split(splitWith) .every((el) => { + if (!el.includes('/') && allowMasks) return false; return validateIPv4(el, allowMasks) || validateIPv6(el, allowMasks); }); }; @@ -76,6 +78,10 @@ export const validateIPv4 = (ip: string, allowMask = false): boolean => { return ipaddr.IPv4.isValidCIDR(ip); } } + const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; + if (!ipv4Pattern.test(ip)) { + return false; + } return ipaddr.IPv4.isValid(ip); }; From da226a8ca11ab1e8bbb2b6f0fcc3f69cc254b39e Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 24 Nov 2025 09:56:41 +0100 Subject: [PATCH 03/17] Fix traffic policy settings styling (#1720) * fix client traffic policy helpers styling * remove unused useMemo deps * tweak the header --- web/src/i18n/en/index.ts | 6 +- web/src/i18n/i18n-types.ts | 12 +-- web/src/i18n/pl/index.ts | 6 +- .../TrafficPolicySelect.tsx | 75 +++++++++++-------- .../components/TrafficPolicySelect/style.scss | 13 ++++ 5 files changed, 69 insertions(+), 43 deletions(-) diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index ce572bb6b..fa88c26ca 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -1718,17 +1718,17 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do none: { label: 'None', helper: - 'When this option is enabled, users will be able to select all routing options.', + 'None - When this option is enabled, users will be able to select all routing options.', }, disableAllTraffic: { label: 'Disable the option to route all traffic through VPN', helper: - 'When this option is enabled, users will not be able to route all traffic through the VPN.', + 'Disable all traffic - When this option is enabled, users will not be able to route all traffic through the VPN.', }, forceAllTraffic: { label: 'Force the clients to route all traffic through VPN', helper: - 'When this option is enabled, the users will always route all traffic through the VPN.', + 'Force all traffic - When this option is enabled, the users will always route all traffic through the VPN.', }, }, }, diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 517c50734..a2ce0a212 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -4103,7 +4103,7 @@ type RootTranslation = { */ label: string /** - * W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​u​s​e​r​s​ ​w​i​l​l​ ​b​e​ ​a​b​l​e​ ​t​o​ ​s​e​l​e​c​t​ ​a​l​l​ ​r​o​u​t​i​n​g​ ​o​p​t​i​o​n​s​. + * N​o​n​e​ ​-​ ​W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​u​s​e​r​s​ ​w​i​l​l​ ​b​e​ ​a​b​l​e​ ​t​o​ ​s​e​l​e​c​t​ ​a​l​l​ ​r​o​u​t​i​n​g​ ​o​p​t​i​o​n​s​. */ helper: string } @@ -4113,7 +4113,7 @@ type RootTranslation = { */ label: string /** - * W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​u​s​e​r​s​ ​w​i​l​l​ ​n​o​t​ ​b​e​ ​a​b​l​e​ ​t​o​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​V​P​N​. + * D​i​s​a​b​l​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​-​ ​W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​u​s​e​r​s​ ​w​i​l​l​ ​n​o​t​ ​b​e​ ​a​b​l​e​ ​t​o​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​V​P​N​. */ helper: string } @@ -4123,7 +4123,7 @@ type RootTranslation = { */ label: string /** - * W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​t​h​e​ ​u​s​e​r​s​ ​w​i​l​l​ ​a​l​w​a​y​s​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​V​P​N​. + * F​o​r​c​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​-​ ​W​h​e​n​ ​t​h​i​s​ ​o​p​t​i​o​n​ ​i​s​ ​e​n​a​b​l​e​d​,​ ​t​h​e​ ​u​s​e​r​s​ ​w​i​l​l​ ​a​l​w​a​y​s​ ​r​o​u​t​e​ ​a​l​l​ ​t​r​a​f​f​i​c​ ​t​h​r​o​u​g​h​ ​t​h​e​ ​V​P​N​. */ helper: string } @@ -10860,7 +10860,7 @@ export type TranslationFunctions = { */ label: () => LocalizedString /** - * When this option is enabled, users will be able to select all routing options. + * None - When this option is enabled, users will be able to select all routing options. */ helper: () => LocalizedString } @@ -10870,7 +10870,7 @@ export type TranslationFunctions = { */ label: () => LocalizedString /** - * When this option is enabled, users will not be able to route all traffic through the VPN. + * Disable all traffic - When this option is enabled, users will not be able to route all traffic through the VPN. */ helper: () => LocalizedString } @@ -10880,7 +10880,7 @@ export type TranslationFunctions = { */ label: () => LocalizedString /** - * When this option is enabled, the users will always route all traffic through the VPN. + * Force all traffic - When this option is enabled, the users will always route all traffic through the VPN. */ helper: () => LocalizedString } diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 7356101f6..325591623 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -1504,17 +1504,17 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe none: { label: 'Brak', helper: - 'Kiedy ta opcja jest włączona, użytkownicy mogą wybierać dowolny typ przekierowania ruchu.', + 'Brak - Kiedy ta opcja jest włączona, użytkownicy mogą wybrać dowolny typ przekierowania ruchu.', }, disableAllTraffic: { label: 'Zablokuj możliwość przekierowania całego ruchu przez VPN', helper: - 'Kiedy ta opcja jest włączona, użytkownicy nie będą mogli przekierować całego ruchu przez VPN.', + 'Zablokuj przekierowanie całego ruchu - Kiedy ta opcja jest włączona, użytkownicy nie będą mogli przekierować całego ruchu przez VPN.', }, forceAllTraffic: { label: 'Wymuś przekierowanie całego ruchu przez VPN', helper: - 'Kiedy ta opcja jest włączona, użytkownicy będą zawsze przekierowywać cały ruch przez VPN.', + 'Wymuś przekierowanie całego ruchu - Kiedy ta opcja jest włączona, użytkownicy będą zawsze przekierowywać cały ruch przez VPN.', }, } }, diff --git a/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/TrafficPolicySelect.tsx b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/TrafficPolicySelect.tsx index 43fa5f3f9..2faf81784 100644 --- a/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/TrafficPolicySelect.tsx +++ b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/TrafficPolicySelect.tsx @@ -1,9 +1,8 @@ import './style.scss'; import clsx from 'clsx'; -import parse from 'html-react-parser'; import { useMemo } from 'react'; import { useI18nContext } from '../../../../../../i18n/i18n-react'; -import { Helper } from '../../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; +import { MessageBox } from '../../../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; import { RadioButton } from '../../../../../../shared/defguard-ui/components/Layout/RadioButton/Radiobutton'; import type { SelectOption } from '../../../../../../shared/defguard-ui/components/Layout/Select/types'; import { ClientTrafficPolicy } from '../../../../../../shared/types'; @@ -21,57 +20,71 @@ export const ClientTrafficPolicySelect = ({ onChange, fieldValue }: Props) => { key: ClientTrafficPolicy.NONE, value: ClientTrafficPolicy.NONE, label: LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.label(), - meta: LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.helper(), }, { key: ClientTrafficPolicy.DISABLE_ALL_TRAFFIC, value: ClientTrafficPolicy.DISABLE_ALL_TRAFFIC, label: LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.label(), - meta: LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.helper(), }, { key: ClientTrafficPolicy.FORCE_ALL_TRAFFIC, value: ClientTrafficPolicy.FORCE_ALL_TRAFFIC, label: LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.label(), - meta: LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.helper(), }, ], [ LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.label, - LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.helper, LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.label, - LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.helper, LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.label, - LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.helper, ], ); return ( -
- - {options.map(({ key, value, label, meta, disabled = false }) => { - const active = fieldValue === value; - return ( -
{ - if (!disabled) { - onChange(value); - } - }} - > -

{label}

- - {parse(meta)} -
- ); - })} +
+
+

{LL.settingsPage.enterprise.fields.clientTrafficPolicy.header()}

+
+
+ +
    +
  • +

    {LL.settingsPage.enterprise.fields.clientTrafficPolicy.none.helper()}

    +
  • +
  • +

    + {LL.settingsPage.enterprise.fields.clientTrafficPolicy.disableAllTraffic.helper()} +

    +
  • +
  • +

    + {LL.settingsPage.enterprise.fields.clientTrafficPolicy.forceAllTraffic.helper()} +

    +
  • +
+
+ {options.map(({ key, value, label, disabled = false }) => { + const active = fieldValue === value; + return ( +
{ + if (!disabled) { + onChange(value); + } + }} + > +

{label}

+ +
+ ); + })} +
); }; diff --git a/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss index 692d1123b..ad3f91753 100644 --- a/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss +++ b/web/src/pages/settings/components/EnterpriseSettings/components/TrafficPolicySelect/style.scss @@ -56,4 +56,17 @@ @include animate-standard; } } + + #client-traffic-policy-message-box { + ul { + list-style-position: inside; + margin-top: 8px; + + li { + p { + display: inline; + } + } + } + } } From 5aa68b2ea2df81e99af6a9bd237c55ab0036ba0a Mon Sep 17 00:00:00 2001 From: jakub-tldr <78603704+jakub-tldr@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:22:28 +0100 Subject: [PATCH 04/17] Fix validator for ipv4 with port (#1723) --- web/src/shared/validators.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts index 8d9966400..0480c722c 100644 --- a/web/src/shared/validators.ts +++ b/web/src/shared/validators.ts @@ -79,9 +79,19 @@ export const validateIPv4 = (ip: string, allowMask = false): boolean => { } } const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; - if (!ipv4Pattern.test(ip)) { + const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}(:\d{1,5})?$/; + if (!ipv4Pattern.test(ip) && !ipv4WithPortPattern.test(ip)) { return false; } + + if (ipv4WithPortPattern.test(ip)) { + const [address, port] = ip.split(':'); + ip = address; + if (!validatePort(port)) { + return false; + } + } + return ipaddr.IPv4.isValid(ip); }; From 3b3dc271ba3096651fca4cba1e735ac6b79bd3e5 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Wed, 26 Nov 2025 10:32:34 +0100 Subject: [PATCH 05/17] fix ipv4 validator (#1726) --- flake.lock | 12 ++++++------ flake.nix | 1 + web/src/shared/validators.ts | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/flake.lock b/flake.lock index 3de9f99f5..8f18766cd 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1763283776, - "narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=", + "lastModified": 1763966396, + "narHash": "sha256-6eeL1YPcY1MV3DDStIDIdy/zZCDKgHdkCmsrLJFiZf0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a", + "rev": "5ae3b07d8d6527c42f17c876e404993199144b6a", "type": "github" }, "original": { @@ -48,11 +48,11 @@ ] }, "locked": { - "lastModified": 1763347184, - "narHash": "sha256-6QH8hpCYJxifvyHEYg+Da0BotUn03BwLIvYo3JAxuqQ=", + "lastModified": 1764124769, + "narHash": "sha256-vcoOEy3i8AGJi3Y2C48hrf6CuL2h8W1gLe1gNt72Kxg=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "08895cce80433978d5bfd668efa41c5e24578cbd", + "rev": "5da8c00313b4434f00aed6b4c94cd3b207bafdc5", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f2e6a984c..1ee1a20d8 100644 --- a/flake.nix +++ b/flake.nix @@ -49,6 +49,7 @@ # Specify the rust-src path (many editors rely on this) RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = true; }; }); } diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts index 0480c722c..44d3da6c5 100644 --- a/web/src/shared/validators.ts +++ b/web/src/shared/validators.ts @@ -79,7 +79,7 @@ export const validateIPv4 = (ip: string, allowMask = false): boolean => { } } const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; - const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}(:\d{1,5})?$/; + const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/; if (!ipv4Pattern.test(ip) && !ipv4WithPortPattern.test(ip)) { return false; } From 2f1af6bc6c23450fdd7e82f64c3b038c7c9c4d97 Mon Sep 17 00:00:00 2001 From: jakub-tldr <78603704+jakub-tldr@users.noreply.github.com> Date: Thu, 27 Nov 2025 13:31:56 +0100 Subject: [PATCH 06/17] RPM config fix (#1730) --- .fpm | 1 + 1 file changed, 1 insertion(+) diff --git a/.fpm b/.fpm index 062ba199b..a03eba34a 100644 --- a/.fpm +++ b/.fpm @@ -3,3 +3,4 @@ --description "Defguard Core service" --url "https://defguard.net/" --maintainer "Defguard" +--config-files /etc/defguard/core.conf From 283ba1223de144bc0a20415c9d62aa54e6684a32 Mon Sep 17 00:00:00 2001 From: jakub-tldr <78603704+jakub-tldr@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:26:30 +0100 Subject: [PATCH 07/17] Validator fix, Frontend unit testing (#1733) * Fix validators Created new patterns, Moved validators to Validate.* Fixed validators in Wizards,smtp configuration * Unit testing for web, unit tests for validators * Created Validate.any/all function. Removed logic from zod * add licenses exceptions --- .github/workflows/test-web.yml | 37 ++ deny.toml | 20 +- web/package.json | 14 +- web/pnpm-lock.yaml | 315 +++++++++++++++++- .../NetworkEditForm/NetworkEditForm.tsx | 43 ++- .../SmtpSettingsForm/SmtpSettingsForm.tsx | 12 +- .../WizardNetworkConfiguration.tsx | 46 ++- .../WizardNetworkImport.tsx | 7 +- web/src/shared/patterns.ts | 11 +- web/src/shared/types.ts | 2 +- web/src/shared/validators.ts | 225 ++++++++----- web/tests/validators.test.ts | 301 +++++++++++++++++ web/vitest.config.mts | 16 + 13 files changed, 907 insertions(+), 142 deletions(-) create mode 100644 .github/workflows/test-web.yml create mode 100644 web/tests/validators.test.ts create mode 100644 web/vitest.config.mts diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml new file mode 100644 index 000000000..d4085f6c7 --- /dev/null +++ b/.github/workflows/test-web.yml @@ -0,0 +1,37 @@ +on: + push: + branches: + - main + - dev + - "release/**" + paths-ignore: + - "*.md" + - "LICENSE" + pull_request: + branches: + - main + - dev + - "release/**" + paths-ignore: + - "*.md" + - "LICENSE" + +jobs: + test-web: + runs-on: + - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@v4 + with: + submodules: "recursive" + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: install deps + working-directory: ./web + run: | + npm i -g npm pnpm + pnpm i --frozen-lockfile + - name: Run tests + working-directory: ./web + run: pnpm run test diff --git a/deny.toml b/deny.toml index 4bbfa288c..49991da9e 100644 --- a/deny.toml +++ b/deny.toml @@ -110,34 +110,34 @@ confidence-threshold = 0.8 # aren't accepted for every possible crate as with the normal allow list exceptions = [ { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_common" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_core" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_mail" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_proto" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_web_ui" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_event_router" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_event_logger" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_version" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "model_derive" }, ] diff --git a/web/package.json b/web/package.json index d3d9ddbd6..031164540 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,10 @@ "typesafe-i18n": "typesafe-i18n", "vite": "vite", "prettier": "prettier", - "biome": "biome" + "biome": "biome", + "test": "vitest run", + "test:ui": "vitest --ui", + "test:watch": "vitest" }, "browserslist": { "production": [ @@ -40,7 +43,10 @@ ], "onlyBuiltDependencies": [ "@swc/core" - ] + ], + "overrides": { + "mdast-util-to-hast": "13.2.1" + } }, "dependencies": { "@floating-ui/react": "^0.27.16", @@ -128,6 +134,7 @@ "@types/react-dom": "^19.2.3", "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react-swc": "^4.2.2", + "@vitest/ui": "^4.0.14", "autoprefixer": "^10.4.22", "concurrently": "^9.2.1", "dotenv": "^17.2.3", @@ -140,6 +147,7 @@ "type-fest": "^4.41.0", "typescript": "~5.9.3", "vite": "^7.2.2", - "vite-plugin-package-version": "^1.1.0" + "vite-plugin-package-version": "^1.1.0", + "vitest": "^4.0.14" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index af6ae5745..5c11d2729 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + mdast-util-to-hast: 13.2.1 + importers: .: @@ -258,6 +261,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^4.2.2 version: 4.2.2(vite@7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + '@vitest/ui': + specifier: ^4.0.14 + version: 4.0.14(vitest@4.0.14) autoprefixer: specifier: ^10.4.22 version: 10.4.22(postcss@8.5.6) @@ -297,6 +303,9 @@ importers: vite-plugin-package-version: specifier: ^1.1.0 version: 1.1.0(vite@7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + vitest: + specifier: ^4.0.14 + version: 4.0.14(@types/node@24.10.1)(@vitest/ui@4.0.14)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) packages: @@ -704,6 +713,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@react-hook/latest@1.0.3': resolution: {integrity: sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==} peerDependencies: @@ -992,6 +1004,9 @@ packages: '@types/byte-size@8.1.2': resolution: {integrity: sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1022,6 +1037,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1107,6 +1125,40 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + '@vitest/expect@4.0.14': + resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + + '@vitest/mocker@4.0.14': + resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.14': + resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} + + '@vitest/runner@4.0.14': + resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + + '@vitest/snapshot@4.0.14': + resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + + '@vitest/spy@4.0.14': + resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + + '@vitest/ui@4.0.14': + resolution: {integrity: sha512-fvDz8o7SQpFLoSBo6Cudv+fE85/fPCkwTnLAN85M+Jv7k59w2mSIjT9Q5px7XwGrmYqqKBEYxh/09IBGd1E7AQ==} + peerDependencies: + vitest: 4.0.14 + + '@vitest/utils@4.0.14': + resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -1142,6 +1194,10 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1226,6 +1282,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.1: + resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -1558,6 +1618,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1589,6 +1652,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -1596,6 +1662,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1611,6 +1681,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -1641,6 +1714,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -2017,6 +2093,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + map-obj@1.0.1: resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} engines: {node: '>=0.10.0'} @@ -2044,8 +2123,8 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - mdast-util-to-hast@13.2.0: - resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -2178,6 +2257,10 @@ packages: react-dom: optional: true + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2221,6 +2304,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + p-limit@1.3.0: resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} engines: {node: '>=4'} @@ -2294,6 +2380,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2631,6 +2720,13 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2667,11 +2763,17 @@ packages: split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-version@9.5.0: resolution: {integrity: sha512-3zWJ/mmZQsOaO+fOlsa0+QK90pwhNd042qEcw6hKFNoLFs7peGyvPffpEBbK/DSGPbyOvli0mUIFv5A4qTjh2Q==} engines: {node: '>=10'} hasBin: true + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2813,14 +2915,28 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2990,12 +3106,51 @@ packages: yaml: optional: true + vitest@4.0.14: + resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.14 + '@vitest/browser-preview': 4.0.14 + '@vitest/browser-webdriverio': 4.0.14 + '@vitest/ui': 4.0.14 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -3466,6 +3621,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@polka/url@1.0.0-next.29': {} + '@react-hook/latest@1.0.3(react@19.2.0)': dependencies: react: 19.2.0 @@ -3681,6 +3838,11 @@ snapshots: '@types/byte-size@8.1.2': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -3709,6 +3871,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -3791,6 +3955,56 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitest/expect@4.0.14': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + chai: 6.2.1 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.14(vite@7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1))': + dependencies: + '@vitest/spy': 4.0.14 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + + '@vitest/pretty-format@4.0.14': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.14': + dependencies: + '@vitest/utils': 4.0.14 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.14': + dependencies: + '@vitest/pretty-format': 4.0.14 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.14': {} + + '@vitest/ui@4.0.14(vitest@4.0.14)': + dependencies: + '@vitest/utils': 4.0.14 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.14(@types/node@24.10.1)(@vitest/ui@4.0.14)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + + '@vitest/utils@4.0.14': + dependencies: + '@vitest/pretty-format': 4.0.14 + tinyrainbow: 3.0.3 + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -3820,6 +4034,8 @@ snapshots: arrify@1.0.1: {} + assertion-error@2.0.1: {} + asynckit@0.4.0: {} autoprefixer@10.4.22(postcss@8.5.6): @@ -3901,6 +4117,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.1: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -4262,6 +4480,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4312,10 +4532,16 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + eventemitter3@5.0.1: {} events@3.3.0: {} + expect-type@1.2.2: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -4324,6 +4550,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -4354,6 +4582,8 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flatted@3.3.3: {} + follow-redirects@1.15.11: {} form-data@4.0.4: @@ -4498,7 +4728,7 @@ snapshots: hast-util-from-parse5: 8.0.3 hast-util-to-parse5: 8.0.0 html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 parse5: 7.3.0 unist-util-position: 5.0.0 unist-util-visit: 5.0.0 @@ -4734,6 +4964,10 @@ snapshots: dependencies: yallist: 4.0.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + map-obj@1.0.1: {} map-obj@4.3.0: {} @@ -4801,7 +5035,7 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 - mdast-util-to-hast@13.2.0: + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -5021,6 +5255,8 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + mrmime@2.0.1: {} + ms@2.1.3: {} n-gram@2.0.2: {} @@ -5057,6 +5293,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.1: {} + p-limit@1.3.0: dependencies: p-try: 1.0.0 @@ -5131,6 +5369,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5236,7 +5476,7 @@ snapshots: devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 react: 19.2.0 remark-parse: 11.0.0 remark-rehype: 11.1.2 @@ -5406,7 +5646,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 unified: 11.0.5 vfile: 6.0.3 @@ -5508,6 +5748,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5544,6 +5792,8 @@ snapshots: dependencies: through: 2.3.8 + stackback@0.0.2: {} + standard-version@9.5.0: dependencies: chalk: 2.4.2 @@ -5561,6 +5811,8 @@ snapshots: stringify-package: 1.0.1 yargs: 16.2.0 + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5734,15 +5986,23 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -5894,10 +6154,53 @@ snapshots: terser: 5.37.0 yaml: 2.6.1 + vitest@4.0.14(@types/node@24.10.1)(@vitest/ui@4.0.14)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1): + dependencies: + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + '@vitest/ui': 4.0.14(vitest@4.0.14) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + web-namespaces@2.0.1: {} which-module@2.0.1: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wordwrap@1.0.0: {} wrap-ansi@6.2.0: diff --git a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx index 772c97a2a..8968939c1 100644 --- a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx +++ b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx @@ -31,11 +31,7 @@ import { } from '../../../shared/types'; import { titleCase } from '../../../shared/utils/titleCase'; import { trimObjectStrings } from '../../../shared/utils/trimObjectStrings.ts'; -import { - validateIpList, - validateIpOrDomain, - validateIpOrDomainList, -} from '../../../shared/validators'; +import { Validate } from '../../../shared/validators'; import { useNetworkPageStore } from '../hooks/useNetworkPageStore'; import { DividerHeader } from './components/DividerHeader.tsx'; @@ -124,15 +120,15 @@ export const NetworkEditForm = () => { .string() .trim() .min(1, LL.form.error.required()) - .refine((value) => { - return validateIpList(value, ',', true); + .refine((val) => { + return Validate.any(val, [Validate.CIDRv4, Validate.CIDRv6], true); }, LL.form.error.addressNetmask()), endpoint: z .string() .trim() .min(1, LL.form.error.required()) .refine( - (val) => validateIpOrDomain(val, false, true), + (val) => Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain]), LL.form.error.endpoint(), ), port: z @@ -140,17 +136,34 @@ export const NetworkEditForm = () => { invalid_type_error: LL.form.error.required(), }) .max(65535, LL.form.error.portMax()), - allowed_ips: z.string(), + allowed_ips: z + .string() + .trim() + .optional() + .refine( + (val) => + Validate.any( + val, + [ + Validate.CIDRv4, + Validate.IPv4, + Validate.CIDRv6, + Validate.IPv6, + Validate.Empty, + ], + true, + ), + LL.form.error.address(), + ), dns: z .string() .trim() .optional() - .refine((val) => { - if (val === '' || !val) { - return true; - } - return validateIpOrDomainList(val, ',', false, true); - }, LL.form.error.allowedIps()), + .refine( + (val) => + Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Empty], true), + LL.form.error.address(), + ), allowed_groups: z.array(z.string().min(1, LL.form.error.minimumLength())), keepalive_interval: z .number({ diff --git a/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx b/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx index 55db9997b..f28d33c02 100644 --- a/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx +++ b/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx @@ -27,7 +27,7 @@ import { patternValidEmail } from '../../../../../../shared/patterns'; import { QueryKeys } from '../../../../../../shared/queries'; import type { SettingsSMTP } from '../../../../../../shared/types'; import { invalidateMultipleQueries } from '../../../../../../shared/utils/invalidateMultipleQueries'; -import { validateIpOrDomain } from '../../../../../../shared/validators'; +import { Validate } from '../../../../../../shared/validators'; import { useSettingsPage } from '../../../../hooks/useSettingsPage'; import { SmtpTestModal } from '../SmtpTest/SmtpTestModal'; import { useSmtpTestModal } from '../SmtpTest/useSmtpTestModal'; @@ -112,8 +112,14 @@ export const SmtpSettingsForm = () => { .trim() .min(1, LL.form.error.required()) .refine( - (val) => (!val ? true : validateIpOrDomain(val, false, true)), - LL.form.error.endpoint(), + (val) => + Validate.any(val, [ + Validate.IPv4, + Validate.IPv6, + Validate.Domain, + Validate.Empty, + ]), + LL.form.error.address(), ), smtp_port: z .number({ diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx index 6ecdd2034..993154b89 100644 --- a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx +++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx @@ -26,11 +26,7 @@ import { QueryKeys } from '../../../../shared/queries'; import { LocationMfaMode, ServiceLocationMode } from '../../../../shared/types.ts'; import { titleCase } from '../../../../shared/utils/titleCase'; import { trimObjectStrings } from '../../../../shared/utils/trimObjectStrings.ts'; -import { - validateIpList, - validateIpOrDomain, - validateIpOrDomainList, -} from '../../../../shared/validators'; +import { Validate } from '../../../../shared/validators'; import { useWizardStore } from '../../hooks/useWizardStore'; import { DividerHeader } from './components/DividerHeader.tsx'; @@ -111,15 +107,17 @@ export const WizardNetworkConfiguration = () => { .string() .trim() .min(1, LL.form.error.required()) - .refine((value) => { - return validateIpList(value, ',', true); - }, LL.form.error.addressNetmask()), + .refine( + (val) => Validate.any(val, [Validate.CIDRv4, Validate.CIDRv6]), + LL.form.error.addressNetmask(), + ), endpoint: z .string() .trim() .min(1, LL.form.error.required()) .refine( - (val) => validateIpOrDomain(val, false, true), + (val) => + Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain], true), LL.form.error.endpoint(), ), port: z @@ -128,17 +126,33 @@ export const WizardNetworkConfiguration = () => { }) .max(65535, LL.form.error.portMax()) .nonnegative(), - allowed_ips: z.string().trim(), + allowed_ips: z + .string() + .trim() + .refine( + (val) => + Validate.any( + val, + [ + Validate.CIDRv4, + Validate.IPv4, + Validate.CIDRv6, + Validate.IPv6, + Validate.Empty, + ], + true, + ), + LL.form.error.address(), + ), dns: z .string() .trim() .optional() - .refine((val) => { - if (val === '' || !val) { - return true; - } - return validateIpOrDomainList(val, ',', true); - }, LL.form.error.allowedIps()), + .refine( + (val) => + Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Empty], true), + LL.form.error.address(), + ), allowed_groups: z.array(z.string().trim().min(1, LL.form.error.minimumLength())), keepalive_interval: z .number({ diff --git a/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx b/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx index 52c798c0e..7f38c6d25 100644 --- a/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx +++ b/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx @@ -27,7 +27,7 @@ import { QueryKeys } from '../../../../shared/queries'; import type { ImportNetworkRequest } from '../../../../shared/types'; import { invalidateMultipleQueries } from '../../../../shared/utils/invalidateMultipleQueries'; import { titleCase } from '../../../../shared/utils/titleCase'; -import { validateIpOrDomain } from '../../../../shared/validators'; +import { Validate } from '../../../../shared/validators'; import { useWizardStore } from '../../hooks/useWizardStore'; interface FormInputs extends Omit { @@ -70,7 +70,10 @@ export const WizardNetworkImport = () => { .string() .trim() .min(1, LL.form.error.required()) - .refine((val) => validateIpOrDomain(val), LL.form.error.endpoint()), + .refine( + (val) => Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain]), + LL.form.error.endpoint(), + ), fileName: z.string().trim().min(1, LL.form.error.required()), config: z.string().trim().min(1, LL.form.error.required()), allowed_groups: z.array(z.string().min(1, LL.form.error.minimumLength())), diff --git a/web/src/shared/patterns.ts b/web/src/shared/patterns.ts index cfb5db0bd..b9c4d6cb7 100644 --- a/web/src/shared/patterns.ts +++ b/web/src/shared/patterns.ts @@ -63,9 +63,14 @@ export const patternValidUrl = new RegExp( '$', 'i', ); - -export const patternValidDomain = - /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9\-.]){1,61}(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))(?::[0-9]{1,5})?$/; +export const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; +export const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/; +export const ipv4WithCIDRPattern = /^(\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/; +export const domainPattern = + /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))$/; + +export const domainWithPortPattern = + /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9\-.]){1,61}(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3})):[0-9]{1,5}$/; export const patternSafeUsernameCharacters = /^[a-zA-Z0-9]+[a-zA-Z0-9.\-_]*$/; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 33258f66b..86bf0baca 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -171,7 +171,7 @@ export type ModifyNetworkRequest = { Network, 'gateways' | 'connected' | 'id' | 'connected_at' | 'allowed_ips' > & { - allowed_ips: string; + allowed_ips?: string; }; }; diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts index 44d3da6c5..23b24bbfc 100644 --- a/web/src/shared/validators.ts +++ b/web/src/shared/validators.ts @@ -1,6 +1,12 @@ import ipaddr from 'ipaddr.js'; import { z } from 'zod'; -import { patternValidDomain, patternValidWireguardKey } from './patterns'; +import { + domainPattern, + ipv4Pattern, + ipv4WithCIDRPattern, + ipv4WithPortPattern, + patternValidWireguardKey, +} from './patterns'; export const validateWireguardPublicKey = (props: { requiredError: string; @@ -17,102 +23,155 @@ export const validateWireguardPublicKey = (props: { .max(44, props.maxError) .regex(patternValidWireguardKey, props.validKeyError); -// Returns false when invalid -export const validateIpOrDomain = ( - val: string, - allowMask = false, - allowIPv6 = false, -): boolean => { - const hasLetter = /\p{L}/u.test(val); - const hasColon = /:/.test(val); - if (!hasLetter || hasColon) { - return (allowIPv6 && validateIPv6(val, allowMask)) || validateIPv4(val, allowMask); - } else { - return patternValidDomain.test(val); - } -}; - -// Returns false when invalid -export const validateIpList = ( - val: string, - splitWith = ',', - allowMasks = false, -): boolean => { - return val - .replace(' ', '') - .split(splitWith) - .every((el) => { - if (!el.includes('/') && allowMasks) return false; - return validateIPv4(el, allowMasks) || validateIPv6(el, allowMasks); - }); -}; - -// Returns false when invalid -export const validateIpOrDomainList = ( - val: string, - splitWith = ',', - allowMasks = false, - allowIPv6 = false, -): boolean => { - const trimmed = val.replace(' ', ''); - const split = trimmed.split(splitWith); - for (const value of split) { - if ( - !validateIPv4(value, allowMasks) && - !patternValidDomain.test(value) && - (!allowIPv6 || !validateIPv6(value, allowMasks)) - ) { - return false; - } - } - return true; -}; - -// Returns false when invalid -export const validateIPv4 = (ip: string, allowMask = false): boolean => { - if (allowMask) { +export const Validate = { + IPv4: (ip: string): boolean => { + if (!ipv4Pattern.test(ip)) { + return false; + } + if (!ipaddr.IPv4.isValid(ip)) { + return false; + } + return true; + }, + IPv4withPort: (ip: string): boolean => { + if (!ipv4WithPortPattern.test(ip)) { + return false; + } + const addr = ip.split(':'); + if (!ipaddr.IPv4.isValid(addr[0]) || !Validate.Port(addr[1])) { + return false; + } + return true; + }, + IPv6: (ip: string): boolean => { + if (!ipaddr.IPv6.isValid(ip)) { + return false; + } + return true; + }, + IPv6withPort: (ip: string): boolean => { + if (ip.includes(']')) { + const address = ip.split(']'); + const ipv6 = address[0].replaceAll('[', '').replaceAll(']', ''); + const port = address[1].replaceAll(']', '').replaceAll(':', ''); + if (!ipaddr.IPv6.isValid(ipv6)) { + return false; + } + if (!Validate.Port(port)) { + return false; + } + } else { + return false; + } + return true; + }, + CIDRv4: (ip: string): boolean => { + if (!ipv4WithCIDRPattern.test(ip)) { + return false; + } if (ip.endsWith('/0')) { return false; } - if (ip.includes('/')) { - return ipaddr.IPv4.isValidCIDR(ip); + if (!ipaddr.IPv4.isValidCIDR(ip)) { + return false; + } + return true; + }, + CIDRv6: (ip: string): boolean => { + if (ip.endsWith('/0')) { + return false; + } + if (!ipaddr.IPv6.isValidCIDR(ip)) { + return false; + } + return true; + }, + Domain: (ip: string): boolean => { + if (!domainPattern.test(ip)) { + return false; + } + return true; + }, + DomainWithPort: (ip: string): boolean => { + const splitted = ip.split(':'); + const domain = splitted[0]; + const port = splitted[1]; + if (!Validate.Port(port)) { + return false; + } + if (!domainPattern.test(domain)) { + return false; + } + return true; + }, + Port: (val: string): boolean => { + const parsed = Number(val); + if (Number.isNaN(parsed) || !Number.isInteger(parsed)) { + return false; + } + return 0 < parsed && parsed <= 65535; + }, + Empty: (val: string): boolean => { + if (val === '' || !val) { + return true; } - } - const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; - const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/; - if (!ipv4Pattern.test(ip) && !ipv4WithPortPattern.test(ip)) { return false; - } + }, + any: ( + value: string | undefined, + validators: Array<(val: string) => boolean>, + allowList: boolean = false, + splitWith = ',', + ): boolean => { + if (!value) { + return true; + } + const items = value.replaceAll(' ', '').split(splitWith); - if (ipv4WithPortPattern.test(ip)) { - const [address, port] = ip.split(':'); - ip = address; - if (!validatePort(port)) { + if (items.length > 1 && !allowList) { return false; } - } - return ipaddr.IPv4.isValid(ip); -}; + for (const item of items) { + let valid = false; + for (const validator of validators) { + if (validator(item)) { + valid = true; + break; + } + } + if (!valid) { + return false; + } + } -export const validateIPv6 = (ip: string, allowMask = false): boolean => { - if (allowMask) { - if (ip.endsWith('/0')) { + return true; + }, + all: ( + value: string | undefined, + validators: Array<(val: string) => boolean>, + allowList: boolean = false, + splitWith = ',', + ): boolean => { + if (!value) { + return true; + } + const items = value.replaceAll(' ', '').split(splitWith); + + if (items.length > 1 && !allowList) { return false; } - if (ip.includes('/')) { - return ipaddr.IPv6.isValidCIDR(ip); + for (const item of items) { + for (const validator of validators) { + if (!validator(item)) { + return false; + } + } } - } - return ipaddr.IPv6.isValid(ip); -}; -export const validatePort = (val: string) => { - const parsed = parseInt(val, 10); - if (!Number.isNaN(parsed)) { - return parsed <= 65535; - } -}; + return true; + }, +} as const; export const numericString = (val: string) => /^\d+$/.test(val); diff --git a/web/tests/validators.test.ts b/web/tests/validators.test.ts new file mode 100644 index 000000000..08f800099 --- /dev/null +++ b/web/tests/validators.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest'; +import { Validate } from '../src/shared/validators'; + +describe('Validate.IPv4', () => { + it('should accept valid IPv4 addresses', () => { + expect(Validate.IPv4('192.168.1.1')).toBe(true); + expect(Validate.IPv4('10.0.0.1')).toBe(true); + expect(Validate.IPv4('172.16.0.1')).toBe(true); + expect(Validate.IPv4('255.255.255.255')).toBe(true); + expect(Validate.IPv4('0.0.0.0')).toBe(true); + }); + + it('should reject invalid IPv4 addresses', () => { + expect(Validate.IPv4('1')).toBe(false); + expect(Validate.IPv4('256.1.1.1')).toBe(false); + expect(Validate.IPv4('192.168.1')).toBe(false); + expect(Validate.IPv4('192.168.1.1.1')).toBe(false); + expect(Validate.IPv4('abc.def.ghi.jkl')).toBe(false); + expect(Validate.IPv4('192.168.1.1/24')).toBe(false); + }); + + it('should reject empty strings', () => { + expect(Validate.IPv4('')).toBe(false); + }); +}); + +describe('Validate.IPv4withPort', () => { + it('should accept valid IPv4 with port', () => { + expect(Validate.IPv4withPort('192.168.1.1:8080')).toBe(true); + expect(Validate.IPv4withPort('10.0.0.1:80')).toBe(true); + expect(Validate.IPv4withPort('127.0.0.1:5051')).toBe(true); + expect(Validate.IPv4withPort('192.168.1.1:65535')).toBe(true); + }); + + it('should reject IPv4 without port', () => { + expect(Validate.IPv4withPort('192.168.1.1')).toBe(false); + }); + + it('should reject invalid port numbers', () => { + expect(Validate.IPv4withPort('192.168.1.1:0')).toBe(false); + expect(Validate.IPv4withPort('192.168.1.1:65536')).toBe(false); + expect(Validate.IPv4withPort('192.168.1.1:99999')).toBe(false); + }); + + it('should reject invalid IPv4 format', () => { + expect(Validate.IPv4withPort('256.1.1.1:8080')).toBe(false); + expect(Validate.IPv4withPort('192.168.1:8080')).toBe(false); + }); +}); + +describe('Validate.IPv6', () => { + it('should accept valid IPv6 addresses', () => { + expect(Validate.IPv6('2001:db8::1')).toBe(true); + expect(Validate.IPv6('::1')).toBe(true); + expect(Validate.IPv6('::')).toBe(true); + expect(Validate.IPv6('2001:0db8:0000:0000:0000:0000:0000:0001')).toBe(true); + expect(Validate.IPv6('fe80::1')).toBe(true); + }); + + it('should reject invalid IPv6 addresses', () => { + expect(Validate.IPv6('192.168.1.1')).toBe(false); + expect(Validate.IPv6('gggg::1')).toBe(false); + expect(Validate.IPv6('invalid')).toBe(false); + }); +}); + +describe('Validate.IPv6withPort', () => { + it('should accept valid IPv6 with port in brackets', () => { + expect(Validate.IPv6withPort('[::1]:8080')).toBe(true); + expect(Validate.IPv6withPort('[2001:db8::1]:80')).toBe(true); + expect(Validate.IPv6withPort('[fe80::1]:65535')).toBe(true); + }); + + it('should reject IPv6 without brackets', () => { + expect(Validate.IPv6withPort('::1:8080')).toBe(false); + expect(Validate.IPv6withPort('2001:db8::1:8080')).toBe(false); + }); + + it('should reject IPv6 without port', () => { + expect(Validate.IPv6withPort('[::1]')).toBe(false); + }); + + it('should reject invalid port numbers', () => { + expect(Validate.IPv6withPort('[::1]:0')).toBe(false); + expect(Validate.IPv6withPort('[::1]:65536')).toBe(false); + }); +}); + +describe('Validate.CIDRv4', () => { + it('should accept valid IPv4 CIDR notation', () => { + expect(Validate.CIDRv4('192.168.1.0/24')).toBe(true); + expect(Validate.CIDRv4('10.0.0.0/8')).toBe(true); + expect(Validate.CIDRv4('172.16.0.0/12')).toBe(true); + expect(Validate.CIDRv4('192.168.1.1/32')).toBe(true); + expect(Validate.CIDRv4('192.168.1.0/1')).toBe(true); + }); + + it('should reject CIDR with /0 mask', () => { + expect(Validate.CIDRv4('192.168.1.0/0')).toBe(false); + }); + + it('should reject invalid CIDR masks', () => { + expect(Validate.CIDRv4('192.168.1.0/33')).toBe(false); + expect(Validate.CIDRv4('192.168.1.0/99')).toBe(false); + }); + + it('should reject IPv4 without CIDR mask', () => { + expect(Validate.CIDRv4('192.168.1.1')).toBe(false); + }); + + it('should reject invalid IPv4 in CIDR', () => { + expect(Validate.CIDRv4('256.1.1.1/24')).toBe(false); + expect(Validate.CIDRv4('192.168.1/24')).toBe(false); + }); +}); + +describe('Validate.CIDRv6', () => { + it('should accept valid IPv6 CIDR notation', () => { + expect(Validate.CIDRv6('2001:db8::/32')).toBe(true); + expect(Validate.CIDRv6('fe80::/10')).toBe(true); + expect(Validate.CIDRv6('::1/128')).toBe(true); + }); + + it('should reject CIDR with /0 mask', () => { + expect(Validate.CIDRv6('2001:db8::/0')).toBe(false); + }); + + it('should reject invalid CIDR masks', () => { + expect(Validate.CIDRv6('2001:db8::/129')).toBe(false); + }); + + it('should reject IPv6 without CIDR mask', () => { + expect(Validate.CIDRv6('2001:db8::1')).toBe(false); + }); +}); + +describe('Validate.Domain', () => { + it('should accept valid domain names', () => { + expect(Validate.Domain('example.com')).toBe(true); + expect(Validate.Domain('sub.example.com')).toBe(true); + expect(Validate.Domain('my-domain.co.uk')).toBe(true); + expect(Validate.Domain('test123.example.org')).toBe(true); + }); + + it('should reject domains with port', () => { + expect(Validate.Domain('example.com:8080')).toBe(false); + }); + + it('should reject invalid domain formats', () => { + expect(Validate.Domain('invalid domain')).toBe(false); + expect(Validate.Domain('example..com')).toBe(false); + expect(Validate.Domain('domain.secret.com')).toBe(true); + }); +}); + +describe('Validate.DomainWithPort', () => { + it('should accept valid domains with port', () => { + expect(Validate.DomainWithPort('example.com:8080')).toBe(true); + expect(Validate.DomainWithPort('sub.example.com:443')).toBe(true); + expect(Validate.DomainWithPort('test.org:3000')).toBe(true); + }); + + it('should reject domains without port', () => { + expect(Validate.DomainWithPort('example.com')).toBe(false); + }); + + it('should reject invalid port numbers', () => { + expect(Validate.DomainWithPort('example.com:0')).toBe(false); + expect(Validate.DomainWithPort('example.com:65536')).toBe(false); + expect(Validate.DomainWithPort('example.com:99999')).toBe(false); + }); +}); + +describe('Validate.Port', () => { + it('should accept valid port numbers', () => { + expect(Validate.Port('1')).toBe(true); + expect(Validate.Port('80')).toBe(true); + expect(Validate.Port('443')).toBe(true); + expect(Validate.Port('8080')).toBe(true); + expect(Validate.Port('65535')).toBe(true); + }); + + it('should reject port 0', () => { + expect(Validate.Port('0')).toBe(false); + }); + + it('should reject ports above 65535', () => { + expect(Validate.Port('65536')).toBe(false); + expect(Validate.Port('99999')).toBe(false); + }); + + it('should reject non-numeric values', () => { + expect(Validate.Port('abc')).toBe(false); + expect(Validate.Port('12.34')).toBe(false); + expect(Validate.Port('')).toBe(false); + }); + + it('should reject negative numbers', () => { + expect(Validate.Port('-1')).toBe(false); + }); +}); + +describe('Validate.any', () => { + it('should accept single valid value matching any validator', () => { + expect(Validate.any('192.168.1.1', [Validate.IPv4, Validate.IPv6])).toBe(true); + expect(Validate.any('2001:db8::1', [Validate.IPv4, Validate.IPv6])).toBe(true); + expect(Validate.any('example.com', [Validate.Domain, Validate.IPv4])).toBe(true); + }); + + it('should reject single value not matching any validator', () => { + expect(Validate.any('invalid', [Validate.IPv4, Validate.IPv6])).toBe(false); + expect(Validate.any('256.1.1.1', [Validate.IPv4, Validate.IPv6])).toBe(false); + }); + + it('should reject multiple values when allowList is false (default)', () => { + expect(Validate.any('192.168.1.1,10.0.0.1', [Validate.IPv4])).toBe(false); + expect(Validate.any('example.com,test.com', [Validate.Domain])).toBe(false); + }); + + it('should accept multiple valid values when allowList is true', () => { + expect(Validate.any('192.168.1.1,10.0.0.1', [Validate.IPv4], true)).toBe(true); + expect( + Validate.any('192.168.1.1,2001:db8::1', [Validate.IPv4, Validate.IPv6], true), + ).toBe(true); + expect(Validate.any('example.com,test.org', [Validate.Domain], true)).toBe(true); + }); + + it('should reject list with any invalid value when allowList is true', () => { + expect(Validate.any('192.168.1.1,invalid', [Validate.IPv4], true)).toBe(false); + expect(Validate.any('192.168.1.1,256.1.1.1', [Validate.IPv4], true)).toBe(false); + }); + + it('should handle mixed valid values with allowList', () => { + expect( + Validate.any( + '192.168.1.1,2001:db8::1,10.0.0.1', + [Validate.IPv4, Validate.IPv6], + true, + ), + ).toBe(true); + expect( + Validate.any('example.com,192.168.1.1', [Validate.Domain, Validate.IPv4], true), + ).toBe(true); + }); + + it('should handle custom split character', () => { + expect(Validate.any('192.168.1.1;10.0.0.1', [Validate.IPv4], true, ';')).toBe(true); + expect(Validate.any('192.168.1.1|10.0.0.1', [Validate.IPv4], true, '|')).toBe(true); + }); + + it('should handle whitespace in list', () => { + expect(Validate.any('192.168.1.1, 10.0.0.1', [Validate.IPv4], true)).toBe(true); + expect(Validate.any('192.168.1.1 , 10.0.0.1', [Validate.IPv4], true)).toBe(true); + }); + + it('should accept empty string with Empty validator in list', () => { + expect(Validate.any('', [Validate.IPv4, Validate.Empty], true)).toBe(true); + }); +}); + +describe('Validate.all', () => { + it('should accept single value matching all validators', () => { + expect(Validate.all('192.168.1.1', [Validate.IPv4])).toBe(true); + }); + + it('should reject single value not matching all validators', () => { + expect(Validate.all('192.168.1.1', [Validate.IPv4, Validate.IPv6])).toBe(false); + expect(Validate.all('invalid', [Validate.IPv4])).toBe(false); + }); + + it('should accept empty string or undefined', () => { + expect(Validate.all('', [Validate.IPv4])).toBe(true); + expect(Validate.all(undefined, [Validate.IPv4])).toBe(true); + }); + + it('should reject multiple values when allowList is false (default)', () => { + expect(Validate.all('192.168.1.1,10.0.0.1', [Validate.IPv4])).toBe(false); + }); + + it('should accept multiple valid values when allowList is true', () => { + expect(Validate.all('192.168.1.1,10.0.0.1', [Validate.IPv4], true)).toBe(true); + expect(Validate.all('example.com,test.org', [Validate.Domain], true)).toBe(true); + }); + + it('should reject if any value does not match all validators when allowList is true', () => { + expect(Validate.all('192.168.1.1,invalid', [Validate.IPv4], true)).toBe(false); + expect(Validate.all('192.168.1.1,256.1.1.1', [Validate.IPv4], true)).toBe(false); + }); + + it('should handle custom split character', () => { + expect(Validate.all('192.168.1.1;10.0.0.1', [Validate.IPv4], true, ';')).toBe(true); + expect(Validate.all('192.168.1.1|10.0.0.1', [Validate.IPv4], true, '|')).toBe(true); + }); + + it('should handle whitespace in list', () => { + expect(Validate.all('192.168.1.1, 10.0.0.1', [Validate.IPv4], true)).toBe(true); + expect( + Validate.all('192.168.1.1 , 10.0.0.1 , 172.16.0.1', [Validate.IPv4], true), + ).toBe(true); + }); +}); diff --git a/web/vitest.config.mts b/web/vitest.config.mts new file mode 100644 index 000000000..06e004bc5 --- /dev/null +++ b/web/vitest.config.mts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import * as path from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + }, + resolve: { + alias: { + '@scss': path.resolve(__dirname, './src/shared/scss'), + '@scssutils': path.resolve(__dirname, './src/shared/scss/global'), + }, + }, +}); From c15367f085c196eab4d882d3433de4e713ed37cd Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:41:36 +0100 Subject: [PATCH 08/17] Fix e2e test (#1742) --- e2e/tests/vpn/wizard.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/vpn/wizard.spec.ts b/e2e/tests/vpn/wizard.spec.ts index 1f9f8c48c..bd6756cc6 100644 --- a/e2e/tests/vpn/wizard.spec.ts +++ b/e2e/tests/vpn/wizard.spec.ts @@ -44,7 +44,7 @@ test.describe('Setup VPN (wizard) ', () => { await page.getByTestId('setup-option-import').click(); await navNext.click(); await page.getByTestId('field-name').fill('test network'); - await page.getByTestId('field-endpoint').fill('127.0.0.1:5051'); + await page.getByTestId('field-endpoint').fill('127.0.0.1'); const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByTestId('upload-config').click(); const responseImportConfigPromise = page.waitForResponse('**/import'); From 94a6e2ad52e5f33f53efa1a69827300fee20376e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 9 Dec 2025 09:03:50 +0100 Subject: [PATCH 09/17] update protos submodule --- proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto b/proto index 74d60d917..5dfc8c8d2 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 74d60d9171048ba0ccaf8a21b05950fb7a673f09 +Subproject commit 5dfc8c8d23ac0613108a2b7b921fd9a97613bb3a From a9cf75c4416c9b777b8ca4ce687a060a1ebaec69 Mon Sep 17 00:00:00 2001 From: Maciek <19913370+wojcik91@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:35:26 +0100 Subject: [PATCH 10/17] Potential fix for code scanning alert no. 60: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/test-web.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml index d4085f6c7..2c1b81327 100644 --- a/.github/workflows/test-web.yml +++ b/.github/workflows/test-web.yml @@ -16,6 +16,8 @@ on: - "*.md" - "LICENSE" +permissions: + contents: read jobs: test-web: runs-on: From a6fa3ea88fab93f3bd2b493de4244429d446bfe9 Mon Sep 17 00:00:00 2001 From: Maciek <19913370+wojcik91@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:47:22 +0100 Subject: [PATCH 11/17] Add support for license tiers (#1746) * add tier field to license metadata * add tier to internal license representation & handle conversion from proto * change naming convention * rename feature gate helper * change naming again * add license tier validation * add tests * add license tier tests * remove unused import * fix comment * pass license tier info to UI * update inputs * disable service location mode in network form * rename helper function * update location wizard * update license tests * Update crates/defguard_core/src/enterprise/license.rs Co-authored-by: Adam * fix comparison --------- Co-authored-by: Adam --- crates/defguard_core/build.rs | 2 +- crates/defguard_core/src/auth/mod.rs | 4 +- .../defguard_core/src/db/models/wireguard.rs | 5 +- .../activity_log_stream_manager.rs | 6 +- .../db/models/enterprise_settings.rs | 4 +- .../src/enterprise/directory_sync/mod.rs | 10 +- .../src/enterprise/firewall/mod.rs | 4 +- .../src/enterprise/grpc/desktop_client_mfa.rs | 4 +- .../src/enterprise/grpc/polling.rs | 4 +- .../src/enterprise/handlers/mod.rs | 4 +- .../defguard_core/src/enterprise/ldap/mod.rs | 6 +- .../defguard_core/src/enterprise/license.rs | 118 +++++++++++++++-- crates/defguard_core/src/enterprise/limits.rs | 85 ++++++------ crates/defguard_core/src/enterprise/mod.rs | 125 +++++++++++++++++- .../src/enterprise/proto/license.proto | 7 + crates/defguard_core/src/grpc/client_mfa.rs | 4 +- crates/defguard_core/src/grpc/mod.rs | 6 +- crates/defguard_core/src/handlers/app_info.rs | 10 +- .../defguard_core/src/handlers/wireguard.rs | 4 +- crates/defguard_core/src/utility_thread.rs | 6 +- .../tests/integration/api/common/mod.rs | 3 +- .../tests/integration/api/openid_login.rs | 3 +- .../tests/integration/grpc/common/mod.rs | 3 +- crates/defguard_version/src/lib.rs | 2 +- flake.lock | 12 +- web/src/i18n/en/index.ts | 1 + web/src/i18n/i18n-types.ts | 8 ++ .../NetworkEditForm/NetworkEditForm.tsx | 32 ++++- .../WizardNetworkConfiguration.tsx | 35 ++++- web/src/shared/types.ts | 6 + 30 files changed, 400 insertions(+), 123 deletions(-) diff --git a/crates/defguard_core/build.rs b/crates/defguard_core/build.rs index 96c212192..d3deeca18 100644 --- a/crates/defguard_core/build.rs +++ b/crates/defguard_core/build.rs @@ -9,6 +9,6 @@ fn main() -> Result<(), Box> { &["src/enterprise/proto/license.proto"], &["src/enterprise/proto"], )?; - println!("cargo:rerun-if-changed=src/enterprise"); + println!("cargo:rerun-if-changed=src/enterprise/proto"); Ok(()) } diff --git a/crates/defguard_core/src/auth/mod.rs b/crates/defguard_core/src/auth/mod.rs index 462f904fa..3077df406 100644 --- a/crates/defguard_core/src/auth/mod.rs +++ b/crates/defguard_core/src/auth/mod.rs @@ -18,7 +18,7 @@ use crate::{ Group, OAuth2Token, Session, SessionState, User, models::{group::Permission, oauth2client::OAuth2Client}, }, - enterprise::{db::models::api_tokens::ApiToken, is_enterprise_enabled}, + enterprise::{db::models::api_tokens::ApiToken, is_business_license_active}, error::WebError, handlers::SESSION_COOKIE_NAME, }; @@ -38,7 +38,7 @@ where let appstate = AppState::from_ref(state); // first try to authenticate by API token if one is found in header - if is_enterprise_enabled() { + if is_business_license_active() { let maybe_auth_header: Option>> = as OptionalFromRequestParts>::from_request_parts(parts, state) .await diff --git a/crates/defguard_core/src/db/models/wireguard.rs b/crates/defguard_core/src/db/models/wireguard.rs index 33c26e498..91966330f 100644 --- a/crates/defguard_core/src/db/models/wireguard.rs +++ b/crates/defguard_core/src/db/models/wireguard.rs @@ -40,7 +40,7 @@ use super::{ wireguard_peer_stats::WireguardPeerStats, }; use crate::{ - enterprise::{firewall::FirewallError, is_enterprise_enabled}, + enterprise::{firewall::FirewallError, is_enterprise_license_active}, grpc::gateway::{send_multiple_wireguard_events, state::GatewayState}, wg_config::ImportedDevice, }; @@ -1335,7 +1335,8 @@ impl WireguardNetwork { /// - Enterprise is enabled #[must_use] pub fn should_prevent_service_location_usage(&self) -> bool { - self.service_location_mode != ServiceLocationMode::Disabled && !is_enterprise_enabled() + self.service_location_mode != ServiceLocationMode::Disabled + && !is_enterprise_license_active() } } diff --git a/crates/defguard_core/src/enterprise/activity_log_stream/activity_log_stream_manager.rs b/crates/defguard_core/src/enterprise/activity_log_stream/activity_log_stream_manager.rs index 3faff81ab..e1a2a467b 100644 --- a/crates/defguard_core/src/enterprise/activity_log_stream/activity_log_stream_manager.rs +++ b/crates/defguard_core/src/enterprise/activity_log_stream/activity_log_stream_manager.rs @@ -10,7 +10,7 @@ use super::ActivityLogStreamReconfigurationNotification; use crate::enterprise::{ activity_log_stream::http_stream::{HttpActivityLogStreamConfig, run_http_stream_task}, db::models::activity_log_stream::{ActivityLogStream, ActivityLogStreamConfig}, - is_enterprise_enabled, + is_business_license_active, }; // check if enterprise features are enabled every minute @@ -27,7 +27,7 @@ pub async fn run_activity_log_stream_manager( let mut enterprise_check_timer = interval(Duration::from_secs(ENTERPRISE_CHECK_PERIOD_SECS)); // initialize enterprise features status - let mut enterprise_features_enabled = is_enterprise_enabled(); + let mut enterprise_features_enabled = is_business_license_active(); loop { let mut handles = JoinSet::<()>::new(); @@ -94,7 +94,7 @@ pub async fn run_activity_log_stream_manager( } _ = enterprise_check_timer.tick() => { // check if enterprise features status has changed - let current_enterprise_features_enabled = is_enterprise_enabled(); + let current_enterprise_features_enabled = is_business_license_active(); if current_enterprise_features_enabled != enterprise_features_enabled { warn!("Activity log stream manager will reload, detected license enterprise features status has changed"); enterprise_features_enabled = current_enterprise_features_enabled; diff --git a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs index d1c9be350..916417a97 100644 --- a/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs +++ b/crates/defguard_core/src/enterprise/db/models/enterprise_settings.rs @@ -1,7 +1,7 @@ use sqlx::{PgExecutor, Type, query, query_as}; use struct_patch::Patch; -use crate::enterprise::is_enterprise_enabled; +use crate::enterprise::is_business_license_active; #[derive(Debug, Deserialize, Patch, Serialize)] #[patch(attribute(derive(Deserialize, Serialize)))] @@ -35,7 +35,7 @@ impl EnterpriseSettings { { // avoid holding the rwlock across await, makes the future !Send // and therefore unusable in axum handlers - if is_enterprise_enabled() { + if is_business_license_active() { let settings = query_as!( Self, "SELECT admin_device_management, \ diff --git a/crates/defguard_core/src/enterprise/directory_sync/mod.rs b/crates/defguard_core/src/enterprise/directory_sync/mod.rs index b37fccba5..9f56e8f21 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/mod.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/mod.rs @@ -11,12 +11,12 @@ use sqlx::{PgConnection, PgPool, error::Error as SqlxError}; use thiserror::Error; use tokio::sync::broadcast::Sender; -#[cfg(not(test))] -use super::is_enterprise_enabled; use super::{ db::models::openid_provider::{DirectorySyncTarget, OpenIdProvider}, ldap::utils::ldap_update_users_state, }; +#[cfg(not(test))] +use crate::enterprise::is_business_license_active; use crate::{ db::{GatewayEvent, Group, User}, enterprise::{ @@ -383,7 +383,7 @@ pub(crate) async fn test_directory_sync_connection( pool: &PgPool, ) -> Result<(), DirectorySyncError> { #[cfg(not(test))] - if !is_enterprise_enabled() { + if !is_business_license_active() { debug!("Enterprise is not enabled, skipping testing directory sync connection"); return Ok(()); } @@ -408,7 +408,7 @@ pub(crate) async fn sync_user_groups_if_configured( wg_tx: &Sender, ) -> Result<(), DirectorySyncError> { #[cfg(not(test))] - if !is_enterprise_enabled() { + if !is_business_license_active() { debug!("Enterprise is not enabled, skipping syncing user groups"); return Ok(()); } @@ -966,7 +966,7 @@ pub(crate) async fn do_directory_sync( wireguard_tx: &Sender, ) -> Result<(), DirectorySyncError> { #[cfg(not(test))] - if !is_enterprise_enabled() { + if !is_business_license_active() { debug!("Enterprise is not enabled, skipping performing directory sync"); return Ok(()); } diff --git a/crates/defguard_core/src/enterprise/firewall/mod.rs b/crates/defguard_core/src/enterprise/firewall/mod.rs index 5e2b7e8d9..eebb8bcc5 100644 --- a/crates/defguard_core/src/enterprise/firewall/mod.rs +++ b/crates/defguard_core/src/enterprise/firewall/mod.rs @@ -23,7 +23,7 @@ use crate::{ db::{Device, User, WireguardNetwork}, enterprise::{ db::models::{acl::AliasKind, snat::UserSnatBinding}, - is_enterprise_enabled, + is_business_license_active, }, }; @@ -903,7 +903,7 @@ impl WireguardNetwork { conn: &mut PgConnection, ) -> Result, FirewallError> { // do a license check - if !is_enterprise_enabled() { + if !is_business_license_active() { debug!( "Enterprise features are disabled, skipping generating firewall config for \ location {self}" diff --git a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs index 6e5e8d032..b76c9170c 100644 --- a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs @@ -6,7 +6,7 @@ use tonic::Status; use crate::{ enterprise::{ handlers::openid_login::{extract_state_data, user_from_claims}, - is_enterprise_enabled, + is_business_license_active, }, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, grpc::{ @@ -23,7 +23,7 @@ impl ClientMfaServer { info: Option, ) -> Result<(), Status> { debug!("Received OIDC MFA authentication request: {request:?}"); - if !is_enterprise_enabled() { + if !is_business_license_active() { error!("OIDC MFA method requires enterprise feature to be enabled"); return Err(Status::invalid_argument("OIDC MFA method is not supported")); } diff --git a/crates/defguard_core/src/enterprise/grpc/polling.rs b/crates/defguard_core/src/enterprise/grpc/polling.rs index 8e04e9a41..e210dabbd 100644 --- a/crates/defguard_core/src/enterprise/grpc/polling.rs +++ b/crates/defguard_core/src/enterprise/grpc/polling.rs @@ -5,7 +5,7 @@ use tonic::Status; use crate::{ db::{Device, User, models::polling_token::PollingToken}, - enterprise::is_enterprise_enabled, + enterprise::is_business_license_active, grpc::utils::build_device_config_response, }; @@ -24,7 +24,7 @@ impl PollingServer { debug!("Validating polling token. Token: {token}"); // Polling service is enterprise-only, check the lincense - if !is_enterprise_enabled() { + if !is_business_license_active() { debug!("Instance has enterprise features disabled, denying instance polling info"); return Err(Status::failed_precondition("no valid license")); } diff --git a/crates/defguard_core/src/enterprise/handlers/mod.rs b/crates/defguard_core/src/enterprise/handlers/mod.rs index dac781bcf..5686717e6 100644 --- a/crates/defguard_core/src/enterprise/handlers/mod.rs +++ b/crates/defguard_core/src/enterprise/handlers/mod.rs @@ -17,7 +17,7 @@ use axum::{ }; use super::{ - db::models::enterprise_settings::EnterpriseSettings, is_enterprise_enabled, + db::models::enterprise_settings::EnterpriseSettings, is_business_license_active, license::get_cached_license, }; use crate::{appstate::AppState, error::WebError}; @@ -37,7 +37,7 @@ where type Rejection = WebError; async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result { - if is_enterprise_enabled() { + if is_business_license_active() { Ok(LicenseInfo { valid: true }) } else { Err(WebError::Forbidden( diff --git a/crates/defguard_core/src/enterprise/ldap/mod.rs b/crates/defguard_core/src/enterprise/ldap/mod.rs index 17152ee73..b3e97a556 100644 --- a/crates/defguard_core/src/enterprise/ldap/mod.rs +++ b/crates/defguard_core/src/enterprise/ldap/mod.rs @@ -18,7 +18,7 @@ use sync::{get_ldap_sync_status, is_ldap_desynced, set_ldap_sync_status}; use self::error::LdapError; use crate::{ db::{self, User}, - enterprise::{is_enterprise_enabled, ldap::model::extract_dn_path, limits::update_counts}, + enterprise::{is_business_license_active, ldap::model::extract_dn_path, limits::update_counts}, }; #[cfg(not(test))] @@ -54,7 +54,7 @@ pub(crate) async fn do_ldap_sync(pool: &PgPool) -> Result<(), LdapError> { return Ok(()); } - if !is_enterprise_enabled() { + if !is_business_license_active() { info!( "Enterprise features are disabled, not performing LDAP sync and automatically disabling it" ); @@ -100,7 +100,7 @@ where F: Future>, { let settings = Settings::get_current_settings(); - if !is_enterprise_enabled() { + if !is_business_license_active() { info!("Enterprise features are disabled, not performing LDAP operation"); set_ldap_sync_status(LdapSyncStatus::OutOfSync, pool).await?; return Err(LdapError::EnterpriseDisabled("LDAP".to_string())); diff --git a/crates/defguard_core/src/enterprise/license.rs b/crates/defguard_core/src/enterprise/license.rs index 2bec3c474..2f0f8b67e 100644 --- a/crates/defguard_core/src/enterprise/license.rs +++ b/crates/defguard_core/src/enterprise/license.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{fmt::Display, time::Duration}; use anyhow::Result; use base64::prelude::*; @@ -20,7 +20,9 @@ use thiserror::Error; use tokio::time::sleep; use super::limits::Counts; -use crate::grpc::proto::enterprise::license::{LicenseKey, LicenseLimits, LicenseMetadata}; +use crate::grpc::proto::enterprise::license::{ + LicenseKey, LicenseLimits, LicenseMetadata, LicenseTier as LicenseTierProto, +}; const LICENSE_SERVER_URL: &str = "https://pkgs.defguard.net/api/license/renew"; @@ -195,6 +197,8 @@ pub enum LicenseError { "License limits exceeded. To upgrade your license please contact salesdefguard.net" )] LicenseLimitsExceeded, + #[error("License tier is lower than required minimum")] + LicenseTierTooLow, } #[derive(Debug, Serialize, Deserialize)] @@ -202,6 +206,28 @@ struct RefreshRequestResponse { key: String, } +/// Represents license tiers +/// +/// Variant order must be maintained to go from lowest (first) to highest (last) tier +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, PartialOrd)] +pub enum LicenseTier { + Business, // this corresponds to both Team & Business level in our current pricing structure + Enterprise, +} + +impl Display for LicenseTier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Business => { + write!(f, "Business") + } + Self::Enterprise => { + write!(f, "Enterprise") + } + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct License { pub customer_id: String, @@ -209,6 +235,7 @@ pub struct License { pub valid_until: Option>, pub limits: Option, pub version_date_limit: Option>, + pub tier: LicenseTier, } impl License { @@ -219,6 +246,7 @@ impl License { valid_until: Option>, limits: Option, version_date_limit: Option>, + tier: LicenseTier, ) -> Self { Self { customer_id, @@ -226,6 +254,7 @@ impl License { valid_until, limits, version_date_limit, + tier, } } @@ -306,12 +335,27 @@ impl License { None => None, }; + let license_tier = match LicenseTierProto::try_from(metadata.tier) { + Ok(LicenseTierProto::Enterprise) => LicenseTier::Enterprise, + // fall back to Business tier for legacy licenses + Ok(LicenseTierProto::Business | LicenseTierProto::Unspecified) => { + LicenseTier::Business + } + Err(err) => { + error!("Failed to read license tier from license metadata: {err}"); + return Err(LicenseError::DecodeError( + "Failed to decode license tier metadata".into(), + )); + } + }; + let license = License::new( metadata.customer_id, metadata.subscription, valid_until, metadata.limits, version_date_limit, + license_tier, ); if license.requires_renewal() { @@ -448,6 +492,14 @@ impl License { self.is_expired() } } + + // Checks if License tier is lower than specified minimum + // + // Ordering is implemented by the `LicenseTier` enum itself + #[must_use] + pub(crate) fn is_lower_tier(&self, minimum_tier: LicenseTier) -> bool { + self.tier < minimum_tier + } } /// Exchange the currently stored key for a new one from the license server. @@ -510,9 +562,11 @@ async fn renew_license() -> Result { /// 1. Does the cached license exist /// 2. Is the cached license past its maximum expiry date /// 3. Does current object count exceed license limits +/// 4. Is the license of at least the specified tier (or higher) pub(crate) fn validate_license( license: Option<&License>, counts: &Counts, + minimum_tier: LicenseTier, ) -> Result<(), LicenseError> { debug!("Validating if the license is present, not expired and not exceeding limits..."); match license { @@ -523,6 +577,9 @@ pub(crate) fn validate_license( if counts.is_over_license_limits(license) { return Err(LicenseError::LicenseLimitsExceeded); } + if license.is_lower_tier(minimum_tier) { + return Err(LicenseError::LicenseTierTooLow); + } Ok(()) } None => Err(LicenseError::LicenseNotFound), @@ -695,6 +752,9 @@ mod test { assert_eq!(limits.users, 10); assert_eq!(limits.devices, 100); assert_eq!(limits.locations, 5); + + // pre-1.6 license defaults to Business tier + assert_eq!(license.tier, LicenseTier::Business); } #[test] @@ -713,6 +773,9 @@ mod test { // legacy license is unlimited assert!(license.limits.is_none()); + + // legacy license defaults to Business tier + assert_eq!(license.tier, LicenseTier::Business); } #[test] @@ -728,6 +791,9 @@ mod test { license.valid_until.unwrap(), Utc.with_ymd_and_hms(2024, 12, 26, 13, 57, 54).unwrap() ); + + // pre-1.6 license defaults to Business tier + assert_eq!(license.tier, LicenseTier::Business); } #[test] @@ -735,8 +801,8 @@ mod test { let license = "CigKIDBjNGRjYjU0MDA1NDRkNDdhZDg2MTdmY2RmMjcwNGNiGOLBtbsGErUBiLMEAAEIAB0WIQSaLjwX4m6jCO3NypmohGwBApqEhAUCZ3ZjywAKCRCohGwBApqEhEwFBACpHDnIszU2+KZcGhi3kycd3a12PyXJuFhhY4cuSyC8YEND85BplSWK1L8nu5ghFULFlddXP9HTHdxhJbtx4SgOQ8pxUY3+OpBN4rfJOMF61tvMRLaWlz7FWm/RnHe8cpoAOYm4oKRS0+FA2qLThxSsVa+S907ty19c6mcDgi6V5g=="; let license = License::from_base64(license).unwrap(); let counts = Counts::default(); - assert!(validate_license(Some(&license), &counts).is_err()); - assert!(validate_license(None, &counts).is_err()); + assert!(validate_license(Some(&license), &counts, LicenseTier::Business).is_err()); + assert!(validate_license(None, &counts, LicenseTier::Business).is_err()); // One day past the expiry date, non-subscription license let license = License::new( @@ -745,8 +811,9 @@ mod test { Some(Utc::now() - TimeDelta::days(1)), None, None, + LicenseTier::Business, ); - assert!(validate_license(Some(&license), &counts).is_err()); + assert!(validate_license(Some(&license), &counts, LicenseTier::Business).is_err()); // One day before the expiry date, non-subscription license let license = License::new( @@ -755,12 +822,20 @@ mod test { Some(Utc::now() + TimeDelta::days(1)), None, None, + LicenseTier::Business, ); - assert!(validate_license(Some(&license), &counts).is_ok()); + assert!(validate_license(Some(&license), &counts, LicenseTier::Business).is_ok()); // No expiry date, non-subscription license - let license = License::new("test".to_string(), false, None, None, None); - assert!(validate_license(Some(&license), &counts).is_ok()); + let license = License::new( + "test".to_string(), + false, + None, + None, + None, + LicenseTier::Business, + ); + assert!(validate_license(Some(&license), &counts, LicenseTier::Business).is_ok()); // One day past the maximum overdue date let license = License::new( @@ -769,8 +844,9 @@ mod test { Some(Utc::now() - MAX_OVERDUE_TIME - TimeDelta::days(1)), None, None, + LicenseTier::Business, ); - assert!(validate_license(Some(&license), &counts).is_err()); + assert!(validate_license(Some(&license), &counts, LicenseTier::Business).is_err()); // One day before the maximum overdue date let license = License::new( @@ -779,8 +855,9 @@ mod test { Some(Utc::now() - MAX_OVERDUE_TIME + TimeDelta::days(1)), None, None, + LicenseTier::Business, ); - assert!(validate_license(Some(&license), &counts).is_ok()); + assert!(validate_license(Some(&license), &counts, LicenseTier::Business).is_ok()); let counts = Counts::new(5, 5, 5, 5); @@ -796,8 +873,9 @@ mod test { network_devices: Some(1), }), None, + LicenseTier::Business, ); - assert!(validate_license(Some(&license), &counts).is_err()); + assert!(validate_license(Some(&license), &counts, LicenseTier::Business).is_err()); // Below object count limits let license = License::new( @@ -811,7 +889,23 @@ mod test { network_devices: Some(10), }), None, + LicenseTier::Business, ); - assert!(validate_license(Some(&license), &counts).is_ok()); + assert!(validate_license(Some(&license), &counts, LicenseTier::Business).is_ok()); + } + + #[test] + fn test_license_tiers() { + let legacy_license = "CjAKIDBjNGRjYjU0MDA1NDRkNDdhZDg2MTdmY2RmMjcwNGNiGOLBtbsGIgYIChBkGAUStQGIswQAAQgAHRYhBJouPBfibqMI7c3KmaiEbAECmoSEBQJnd9EMAAoJEKiEbAECmoSE/0kEAIb18pVTEYWQo0w6813nShJqi7++Uo/fX4pxaAzEiG9r5HGpZSbsceCarMiK1rBr93HOIMeDRsbZmJBA/MAYGi32uXgzLE8fGSd4lcUPAbpvlj7KNvQNH6sMelzQVw+AJVY+IASqO84nfy92taEVagbLqIwl/eSQUnehJBS+B5/z"; + let legacy_license = License::from_base64(legacy_license).unwrap(); + assert_eq!(legacy_license.tier, LicenseTier::Business); + + let business_license = "Ci4KJGEyYjE1M2MzLWYwZmEtNGUzNC05ZThkLWY0Nzk1NTA4OWMwNRiI7KTKBjABErUBiLMEAAEIAB0WIQSaLjwX4m6jCO3NypmohGwBApqEhAUCaT/7iAAKCRCohGwBApqEhHdaA/0QqDNiryYSzWTEayBMwEBE6KAxTEtwRzXOxQxsnULjbQMol/SRjqfu8iwlI4IeBQP3CuAR9kglewvwg3osXDldIns46W/cDBd0jxANebLY9SPz0JS6pStMnSzhZ6rFW5ns3nCz86EOyAA9npx0/qxHCbtT6Qzi//5JYQe6VvvCmw=="; + let business_license = License::from_base64(business_license).unwrap(); + assert_eq!(business_license.tier, LicenseTier::Business); + + let enterprise_license = "Ci4KJDRiYjMzZTUyLWUzNGMtNGQyMS1iNDVhLTkxY2EzYTMzNGMwORiy7KTKBjACErUBiLMEAAEIAB0WIQSaLjwX4m6jCO3NypmohGwBApqEhAUCaT/7sgAKCRCohGwBApqEhIMzBACGd7vIyLaRVGV/MAD8bpgWURG1x1tlxD9ehaSNkk01GkfZc+6+QwiTUBUOSp0MKPtuLmow5AIRKS9M75CQQ4bGtjLWO5cXJm1sduRpTvXwPLXNkRFPSxhjHmo4yjFFHMHMySqQE2WUjcz/b5dMT/WNqWYg7tSfT72eiK18eSVFTA=="; + let enterprise_license = License::from_base64(enterprise_license).unwrap(); + assert_eq!(enterprise_license.tier, LicenseTier::Enterprise); } } diff --git a/crates/defguard_core/src/enterprise/limits.rs b/crates/defguard_core/src/enterprise/limits.rs index 6e7d0f66b..ff78223a6 100644 --- a/crates/defguard_core/src/enterprise/limits.rs +++ b/crates/defguard_core/src/enterprise/limits.rs @@ -18,7 +18,7 @@ pub struct Counts { user: u32, user_device: u32, network_device: u32, - wireguard_network: u32, + location: u32, } global_value!(COUNTS, Counts, Counts::default(), set_counts, get_counts); @@ -52,7 +52,7 @@ pub async fn update_counts<'e, E: sqlx::PgExecutor<'e>>(executor: E) -> Result<( .network_devices .try_into() .expect("device count should never be negative"), - wireguard_network: result + location: result .wireguard_networks .try_into() .expect("network count should never be negative"), @@ -77,22 +77,17 @@ impl Counts { Self { user: 0, user_device: 0, - wireguard_network: 0, + location: 0, network_device: 0, } } #[cfg(test)] - pub(crate) fn new( - user: u32, - user_device: u32, - wireguard_network: u32, - network_device: u32, - ) -> Self { + pub(crate) fn new(user: u32, user_device: u32, location: u32, network_device: u32) -> Self { Self { user, user_device, - wireguard_network, + location, network_device, } } @@ -114,7 +109,7 @@ impl Counts { debug!("Cached license not found. Using default limits for validation..."); self.user > DEFAULT_USERS_LIMIT || self.user_device > DEFAULT_DEVICES_LIMIT - || self.wireguard_network > DEFAULT_LOCATIONS_LIMIT + || self.location > DEFAULT_LOCATIONS_LIMIT || self.network_device > DEFAULT_NETWORK_DEVICES_LIMIT } } @@ -135,19 +130,19 @@ impl Counts { Some(limits) => { self.user > limits.users || self.is_over_device_limit(limits) - || self.wireguard_network > limits.locations + || self.location > limits.locations } // unlimited license None => false, } } - /// Checks if current object count exceeds default limits - pub(crate) fn needs_enterprise_license(&self) -> bool { - debug!("Checking if current object counts ({self:?}) exceed default limits"); + /// Checks if current object count exceeds default free tier limits + pub(crate) fn needs_paid_license(&self) -> bool { + debug!("Checking if current object counts ({self:?}) exceed default free tier limits"); self.user > DEFAULT_USERS_LIMIT || self.user_device > DEFAULT_DEVICES_LIMIT - || self.wireguard_network > DEFAULT_LOCATIONS_LIMIT + || self.location > DEFAULT_LOCATIONS_LIMIT || self.network_device > DEFAULT_NETWORK_DEVICES_LIMIT } @@ -161,7 +156,7 @@ impl Counts { } else { self.user_device + self.network_device > limits.devices }, - wireguard_network: self.wireguard_network > limits.locations, + wireguard_network: self.location > limits.locations, network_device: match limits.network_devices { Some(devices) => self.network_device > devices, None => false, @@ -179,7 +174,7 @@ impl Counts { LimitsExceeded { user: self.user > DEFAULT_DEVICES_LIMIT, device: self.user_device > DEFAULT_DEVICES_LIMIT, - wireguard_network: self.wireguard_network > DEFAULT_LOCATIONS_LIMIT, + wireguard_network: self.location > DEFAULT_LOCATIONS_LIMIT, network_device: self.network_device > DEFAULT_NETWORK_DEVICES_LIMIT, } } @@ -208,7 +203,7 @@ mod test { use super::*; use crate::{ - enterprise::license::{License, set_cached_license}, + enterprise::license::{License, LicenseTier, set_cached_license}, grpc::proto::enterprise::license::LicenseLimits, }; @@ -223,7 +218,7 @@ mod test { let counts = Counts { user: 5, user_device: 15, - wireguard_network: 3, + location: 3, network_device: 6, }; assert!(counts.is_over_device_limit(&limits)); @@ -231,7 +226,7 @@ mod test { let counts = Counts { user: 5, user_device: 10, - wireguard_network: 3, + location: 3, network_device: 5, }; assert!(!counts.is_over_device_limit(&limits)); @@ -246,7 +241,7 @@ mod test { let counts = Counts { user: 5, user_device: 15, - wireguard_network: 3, + location: 3, network_device: 6, }; assert!(!counts.is_over_device_limit(&limits)); @@ -254,7 +249,7 @@ mod test { let counts = Counts { user: 5, user_device: 15, - wireguard_network: 3, + location: 3, network_device: 11, }; assert!(counts.is_over_device_limit(&limits)); @@ -265,7 +260,7 @@ mod test { let counts = Counts { user: 1, user_device: 2, - wireguard_network: 3, + location: 3, network_device: 4, }; @@ -275,7 +270,7 @@ mod test { assert_eq!(counts.user, 1); assert_eq!(counts.user_device, 2); - assert_eq!(counts.wireguard_network, 3); + assert_eq!(counts.location, 3); } #[test] @@ -285,7 +280,7 @@ mod test { let counts = Counts { user: DEFAULT_USERS_LIMIT + 1, user_device: 1, - wireguard_network: 1, + location: 1, network_device: 1, }; set_counts(counts); @@ -298,7 +293,7 @@ mod test { let counts = Counts { user: 1, user_device: DEFAULT_DEVICES_LIMIT + 1, - wireguard_network: 1, + location: 1, network_device: 1, }; set_counts(counts); @@ -311,7 +306,7 @@ mod test { let counts = Counts { user: 1, user_device: 1, - wireguard_network: DEFAULT_LOCATIONS_LIMIT + 1, + location: DEFAULT_LOCATIONS_LIMIT + 1, network_device: 1, }; set_counts(counts); @@ -324,7 +319,7 @@ mod test { let counts = Counts { user: 1, user_device: 1, - wireguard_network: 1, + location: 1, network_device: 1, }; set_counts(counts); @@ -337,7 +332,7 @@ mod test { let counts = Counts { user: DEFAULT_USERS_LIMIT + 1, user_device: DEFAULT_DEVICES_LIMIT, - wireguard_network: DEFAULT_LOCATIONS_LIMIT, + location: DEFAULT_LOCATIONS_LIMIT, network_device: 1, }; set_counts(counts); @@ -365,6 +360,7 @@ mod test { Some(Utc::now() + TimeDelta::days(1)), Some(limits), None, + LicenseTier::Business, ); set_cached_license(Some(license)); @@ -373,7 +369,7 @@ mod test { let counts = Counts { user: users_limit + 1, user_device: 1, - wireguard_network: 1, + location: 1, network_device: 1, }; set_counts(counts); @@ -386,7 +382,7 @@ mod test { let counts = Counts { user: 1, user_device: devices_limit + 1, - wireguard_network: 1, + location: 1, network_device: 1, }; set_counts(counts); @@ -399,7 +395,7 @@ mod test { let counts = Counts { user: 1, user_device: 1, - wireguard_network: locations_limit + 1, + location: locations_limit + 1, network_device: 1, }; set_counts(counts); @@ -412,7 +408,7 @@ mod test { let counts = Counts { user: users_limit, user_device: devices_limit, - wireguard_network: locations_limit, + location: locations_limit, network_device: network_devices_limit, }; set_counts(counts); @@ -425,7 +421,7 @@ mod test { let counts = Counts { user: users_limit + 1, user_device: devices_limit + 1, - wireguard_network: locations_limit + 1, + location: locations_limit + 1, network_device: network_devices_limit + 1, }; set_counts(counts); @@ -442,6 +438,7 @@ mod test { Some(Utc::now() + TimeDelta::days(1)), None, None, + LicenseTier::Business, ); set_cached_license(Some(license)); @@ -450,7 +447,7 @@ mod test { let counts = Counts { user: u32::MAX, user_device: u32::MAX, - wireguard_network: u32::MAX, + location: u32::MAX, network_device: u32::MAX, }; set_counts(counts); @@ -469,7 +466,7 @@ mod test { let counts = Counts { user: exceed_user, user_device: 0, - wireguard_network: 0, + location: 0, network_device: 0, }; set_counts(counts); @@ -483,7 +480,7 @@ mod test { let counts = Counts { user: 0, user_device: exceed_device, - wireguard_network: 0, + location: 0, network_device: 0, }; set_counts(counts); @@ -497,7 +494,7 @@ mod test { let counts = Counts { user: 0, user_device: 0, - wireguard_network: exceed_wireguard_network, + location: exceed_wireguard_network, network_device: 0, }; set_counts(counts); @@ -510,7 +507,7 @@ mod test { let counts = Counts { user: 0, user_device: 0, - wireguard_network: 0, + location: 0, network_device: exceed_network_device, }; @@ -525,7 +522,7 @@ mod test { let counts = Counts { user: 0, user_device: 0, - wireguard_network: 0, + location: 0, network_device: 0, }; set_counts(counts); @@ -547,11 +544,12 @@ mod test { network_devices: Some(2), }), None, + LicenseTier::Business, ); let counts = Counts { user: 3, user_device: 3, - wireguard_network: 3, + location: 3, network_device: 3, }; set_counts(counts); @@ -568,11 +566,12 @@ mod test { Some(Utc::now() + TimeDelta::days(1)), None, None, + LicenseTier::Business, ); let counts = Counts { user: 300, user_device: 300, - wireguard_network: 300, + location: 300, network_device: 300, }; set_counts(counts); diff --git a/crates/defguard_core/src/enterprise/mod.rs b/crates/defguard_core/src/enterprise/mod.rs index 679296908..4d16e6a42 100644 --- a/crates/defguard_core/src/enterprise/mod.rs +++ b/crates/defguard_core/src/enterprise/mod.rs @@ -13,17 +13,34 @@ mod utils; use license::{get_cached_license, validate_license}; use limits::get_counts; -pub(crate) fn is_enterprise_enabled() -> bool { - debug!("Checking if enterprise features should be enabled"); +use crate::enterprise::license::LicenseTier; + +/// Helper function to gate features which require a base license (Team or Business tier) +pub(crate) fn is_business_license_active() -> bool { + is_license_tier_active(LicenseTier::Business) +} + +/// Helper function to gate features which require an Enterprise tier license +pub(crate) fn is_enterprise_license_active() -> bool { + is_license_tier_active(LicenseTier::Enterprise) +} + +/// Shared logic for gating features to specific license tiers +fn is_license_tier_active(tier: LicenseTier) -> bool { + debug!("Checking if features for {tier} license tier should be enabled"); + + // get current object counts let counts = get_counts(); - if counts.needs_enterprise_license() { + + // only check license if object count exceed free limit + if counts.needs_paid_license() { debug!("User is over limit, checking his license"); let license = get_cached_license(); - let validation_result = validate_license(license.as_ref(), &counts); + let validation_result = validate_license(license.as_ref(), &counts, tier); debug!("License validation result: {:?}", validation_result); validation_result.is_ok() } else { - debug!("User is not over limit, allowing enterprise features"); + debug!("User is not over limit, allowing {tier} tier features"); true } } @@ -35,9 +52,9 @@ pub(crate) fn is_enterprise_free() -> bool { debug!("Checking if enterprise features are a part of the free version"); let counts = get_counts(); let license = get_cached_license(); - if validate_license(license.as_ref(), &counts).is_ok() { + if validate_license(license.as_ref(), &counts, LicenseTier::Business).is_ok() { false - } else if counts.needs_enterprise_license() { + } else if counts.needs_paid_license() { debug!("User is over limit, the enterprise features are not free"); false } else { @@ -45,3 +62,97 @@ pub(crate) fn is_enterprise_free() -> bool { true } } + +#[cfg(test)] +mod test { + use chrono::{TimeDelta, Utc}; + + use crate::{ + enterprise::{ + is_business_license_active, is_enterprise_free, is_enterprise_license_active, + license::{License, LicenseTier, set_cached_license}, + limits::{Counts, set_counts}, + }, + grpc::proto::enterprise::license::LicenseLimits, + }; + + #[test] + fn test_feature_gates_no_license() { + set_cached_license(None); + + // free limits are not exceeded + let counts = Counts::new(1, 1, 1, 1); + set_counts(counts); + + assert!(is_business_license_active()); + assert!(is_enterprise_license_active()); + assert!(is_enterprise_free()); + + // exceed free limits + let counts = Counts::new(1, 1, 5, 1); + set_counts(counts); + + assert!(!is_business_license_active()); + assert!(!is_enterprise_license_active()); + assert!(!is_enterprise_free()); + } + + #[test] + fn test_feature_gates_with_license() { + // exceed free limits + let counts = Counts::new(1, 1, 5, 1); + set_counts(counts); + + // set Business license + let users_limit = 15; + let devices_limit = 35; + let locations_limit = 5; + let network_devices_limit = 10; + + let limits = LicenseLimits { + users: users_limit, + devices: devices_limit, + locations: locations_limit, + network_devices: Some(network_devices_limit), + }; + let license = License::new( + "test".to_string(), + true, + Some(Utc::now() + TimeDelta::days(1)), + Some(limits), + None, + LicenseTier::Business, + ); + set_cached_license(Some(license)); + + assert!(is_business_license_active()); + assert!(!is_enterprise_license_active()); + assert!(!is_enterprise_free()); + + // set Enterprise license + let users_limit = 15; + let devices_limit = 35; + let locations_limit = 5; + let network_devices_limit = 10; + + let limits = LicenseLimits { + users: users_limit, + devices: devices_limit, + locations: locations_limit, + network_devices: Some(network_devices_limit), + }; + let license = License::new( + "test".to_string(), + true, + Some(Utc::now() + TimeDelta::days(1)), + Some(limits), + None, + LicenseTier::Enterprise, + ); + set_cached_license(Some(license)); + + assert!(is_business_license_active()); + assert!(is_enterprise_license_active()); + assert!(!is_enterprise_free()); + } +} diff --git a/crates/defguard_core/src/enterprise/proto/license.proto b/crates/defguard_core/src/enterprise/proto/license.proto index 098548a45..a8dfc170c 100644 --- a/crates/defguard_core/src/enterprise/proto/license.proto +++ b/crates/defguard_core/src/enterprise/proto/license.proto @@ -8,12 +8,19 @@ message LicenseLimits { optional uint32 network_devices = 4; } +enum LicenseTier { + LICENSE_TIER_UNSPECIFIED = 0; + LICENSE_TIER_BUSINESS = 1; + LICENSE_TIER_ENTERPRISE = 2; +} + message LicenseMetadata { string customer_id = 1; bool subscription = 2; optional int64 valid_until = 3; LicenseLimits limits = 4; optional int64 version_date_limit = 5; + LicenseTier tier = 6; } message LicenseKey { diff --git a/crates/defguard_core/src/grpc/client_mfa.rs b/crates/defguard_core/src/grpc/client_mfa.rs index f688a41a4..4d38504c8 100644 --- a/crates/defguard_core/src/grpc/client_mfa.rs +++ b/crates/defguard_core/src/grpc/client_mfa.rs @@ -30,7 +30,7 @@ use crate::{ wireguard::LocationMfaMode, }, }, - enterprise::{db::models::openid_provider::OpenIdProvider, is_enterprise_enabled}, + enterprise::{db::models::openid_provider::OpenIdProvider, is_business_license_active}, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, grpc::utils::parse_client_ip_agent, handlers::mail::send_email_mfa_code_email, @@ -259,7 +259,7 @@ impl ClientMfaServer { })?; } MfaMethod::Oidc => { - if !is_enterprise_enabled() { + if !is_business_license_active() { error!("OIDC MFA method requires enterprise feature to be enabled"); return Err(Status::invalid_argument( "selected MFA method not available", diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index a4c4ba3dc..4b82621ab 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -59,7 +59,7 @@ use crate::{ handlers::openid_login::{ SELECT_ACCOUNT_SUPPORTED_PROVIDERS, build_state, make_oidc_client, user_from_claims, }, - is_enterprise_enabled, + is_business_license_active, ldap::utils::ldap_update_user_state, }, events::{BidiStreamEvent, GrpcEvent}, @@ -380,7 +380,7 @@ async fn handle_proxy_message_loop( } } Some(core_request::Payload::AuthInfo(request)) => { - if !is_enterprise_enabled() { + if !is_business_license_active() { warn!("Enterprise license required"); Some(core_response::Payload::CoreError(CoreError { status_code: Code::FailedPrecondition as i32, @@ -833,7 +833,7 @@ impl InstanceInfo { proxy_url: config.enrollment_url.clone(), username: username.into(), client_traffic_policy: enterprise_settings.client_traffic_policy, - enterprise_enabled: is_enterprise_enabled(), + enterprise_enabled: is_business_license_active(), openid_display_name, } } diff --git a/crates/defguard_core/src/handlers/app_info.rs b/crates/defguard_core/src/handlers/app_info.rs index 344ee4192..65ce7d954 100644 --- a/crates/defguard_core/src/handlers/app_info.rs +++ b/crates/defguard_core/src/handlers/app_info.rs @@ -9,8 +9,8 @@ use crate::{ db::WireguardNetwork, enterprise::{ db::models::openid_provider::OpenIdProvider, - is_enterprise_enabled, is_enterprise_free, - license::get_cached_license, + is_business_license_active, is_enterprise_free, + license::{LicenseTier, get_cached_license}, limits::{LimitsExceeded, get_counts}, }, }; @@ -25,6 +25,8 @@ struct LicenseInfo { any_limit_exceeded: bool, /// Whether the enterprise features are used for free. is_enterprise_free: bool, + // Which license tier (if any) is active + tier: Option, } #[derive(Serialize)] @@ -55,11 +57,12 @@ pub(crate) async fn get_app_info( let external_openid_enabled = OpenIdProvider::get_current(&appstate.pool).await?.is_some(); let settings = Settings::get_current_settings(); - let enterprise = is_enterprise_enabled(); + let enterprise = is_business_license_active(); let license = get_cached_license(); let counts = get_counts(); let limits_exceeded = counts.get_exceeded_limits(license.as_ref()); let any_limit_exceeded = limits_exceeded.any(); + let tier = license.as_ref().map(|license| license.tier.clone()); let res = AppInfo { network_present: !networks.is_empty(), @@ -70,6 +73,7 @@ pub(crate) async fn get_app_info( limits_exceeded, any_limit_exceeded, is_enterprise_free: is_enterprise_free(), + tier, }, ldap_info: LdapInfo { enabled: settings.ldap_enabled, diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 9410134bd..34a2af7ea 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -40,7 +40,7 @@ use crate::{ enterprise::{ db::models::{enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider}, handlers::CanManageDevices, - is_enterprise_enabled, + is_business_license_active, limits::update_counts, }, events::{ApiEvent, ApiEventType, ApiRequestContext}, @@ -126,7 +126,7 @@ impl WireguardNetworkData { // if external MFA was chosen verify if enterprise features are enabled // and external OpenID provider is configured if self.location_mfa_mode == LocationMfaMode::External { - if !is_enterprise_enabled() { + if !is_business_license_active() { error!( "Unable to create location with external MFA. External OpenID provider is not configured" ); diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 5a0de67b3..d49340a83 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -13,7 +13,7 @@ use crate::{ enterprise::{ db::models::acl::{AclRule, RuleState}, directory_sync::{do_directory_sync, get_directory_sync_interval}, - is_enterprise_enabled, + is_business_license_active, ldap::{do_ldap_sync, sync::get_ldap_sync_interval}, limits::do_count_update, }, @@ -40,7 +40,7 @@ pub async fn run_utility_thread( let mut last_enterprise_status_check = Instant::now(); // helper variable which stores previous enterprise features status - let mut enterprise_enabled = is_enterprise_enabled(); + let mut enterprise_enabled = is_business_license_active(); let directory_sync_task = || async { if let Err(e) = Box::pin( @@ -129,7 +129,7 @@ pub async fn run_utility_thread( // Check if enterprise features got enabled or disabled if last_enterprise_status_check.elapsed().as_secs() >= ENTERPRISE_STATUS_CHECK_INTERVAL { - let new_enterprise_enabled = is_enterprise_enabled(); + let new_enterprise_enabled = is_business_license_active(); if let Err(err) = enterprise_status_check( pool, wireguard_tx.clone(), diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 1c4e22244..4a85f5205 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -15,7 +15,7 @@ use defguard_core::{ auth::failed_login::FailedLoginMap, build_webapp, db::{AppEvent, Device, GatewayEvent, User, UserDetails, WireguardNetwork}, - enterprise::license::{License, set_cached_license}, + enterprise::license::{License, LicenseTier, set_cached_license}, events::ApiEvent, grpc::{WorkerState, gateway::map::GatewayMap}, handlers::Auth, @@ -95,6 +95,7 @@ pub(crate) async fn make_base_client( None, None, None, + LicenseTier::Business, ); set_cached_license(Some(license)); diff --git a/crates/defguard_core/tests/integration/api/openid_login.rs b/crates/defguard_core/tests/integration/api/openid_login.rs index 923633fe1..027d2ae35 100644 --- a/crates/defguard_core/tests/integration/api/openid_login.rs +++ b/crates/defguard_core/tests/integration/api/openid_login.rs @@ -5,7 +5,7 @@ use defguard_core::{ enterprise::{ db::models::openid_provider::{DirectorySyncTarget, DirectorySyncUserBehavior}, handlers::openid_providers::AddProviderData, - license::{License, set_cached_license}, + license::{License, LicenseTier, set_cached_license}, }, handlers::Auth, }; @@ -93,6 +93,7 @@ async fn test_openid_providers(_: PgPoolOptions, options: PgConnectOptions) { Some(Utc::now() - Duration::days(1)), None, None, + LicenseTier::Business, ); set_cached_license(Some(new_license)); let response = client.get("/api/v1/openid/auth_info").send().await; diff --git a/crates/defguard_core/tests/integration/grpc/common/mod.rs b/crates/defguard_core/tests/integration/grpc/common/mod.rs index 96609dbfa..1d2d364bb 100644 --- a/crates/defguard_core/tests/integration/grpc/common/mod.rs +++ b/crates/defguard_core/tests/integration/grpc/common/mod.rs @@ -5,7 +5,7 @@ use defguard_common::db::models::settings::initialize_current_settings; use defguard_core::{ auth::failed_login::FailedLoginMap, db::{AppEvent, GatewayEvent}, - enterprise::license::{License, set_cached_license}, + enterprise::license::{License, LicenseTier, set_cached_license}, events::GrpcEvent, grpc::{ WorkerState, build_grpc_service_router, @@ -147,6 +147,7 @@ pub(crate) async fn make_grpc_test_server(pool: &PgPool) -> TestGrpcServer { None, None, None, + LicenseTier::Business, ); set_cached_license(Some(license)); diff --git a/crates/defguard_version/src/lib.rs b/crates/defguard_version/src/lib.rs index 05f177b24..8d6881440 100644 --- a/crates/defguard_version/src/lib.rs +++ b/crates/defguard_version/src/lib.rs @@ -62,7 +62,7 @@ use std::{cmp::Ordering, fmt, str::FromStr}; -use ::tracing::{error, warn}; +use ::tracing::warn; pub use semver::{BuildMetadata, Error as SemverError, Prerelease, Version}; use serde::Serialize; use thiserror::Error; diff --git a/flake.lock b/flake.lock index 8f18766cd..b2ef29d1e 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1763966396, - "narHash": "sha256-6eeL1YPcY1MV3DDStIDIdy/zZCDKgHdkCmsrLJFiZf0=", + "lastModified": 1765779637, + "narHash": "sha256-KJ2wa/BLSrTqDjbfyNx70ov/HdgNBCBBSQP3BIzKnv4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5ae3b07d8d6527c42f17c876e404993199144b6a", + "rev": "1306659b587dc277866c7b69eb97e5f07864d8c4", "type": "github" }, "original": { @@ -48,11 +48,11 @@ ] }, "locked": { - "lastModified": 1764124769, - "narHash": "sha256-vcoOEy3i8AGJi3Y2C48hrf6CuL2h8W1gLe1gNt72Kxg=", + "lastModified": 1765852971, + "narHash": "sha256-rQdOMqfQNhcfqvh1dFIVWh09mrIWwerUJqqBdhIsf8g=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "5da8c00313b4434f00aed6b4c94cd3b207bafdc5", + "rev": "5f98ccecc9f1bc1c19c0a350a659af1a04b3b319", "type": "github" }, "original": { diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index fa88c26ca..943137873 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -2066,6 +2066,7 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do alwaysOn: 'Always-on - A VPN connection will always be active when the user device is on.', mfaWarning: "Service locations can't be used while location MFA is enabled.", + enterpriseTierWarning: "This feature requires an Enterprise-tier license. If you are interested in using it, please contact our sales team at: sales@defguard.net" }, }, sections: { diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index a2ce0a212..c4fd69694 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -4949,6 +4949,10 @@ type RootTranslation = { * S​e​r​v​i​c​e​ ​l​o​c​a​t​i​o​n​s​ ​c​a​n​'​t​ ​b​e​ ​u​s​e​d​ ​w​h​i​l​e​ ​l​o​c​a​t​i​o​n​ ​M​F​A​ ​i​s​ ​e​n​a​b​l​e​d​. */ mfaWarning: string + /** + * T​h​i​s​ ​f​e​a​t​u​r​e​ ​r​e​q​u​i​r​e​s​ ​a​n​ ​E​n​t​e​r​p​r​i​s​e​-​t​i​e​r​ ​l​i​c​e​n​s​e​.​ ​I​f​ ​y​o​u​ ​a​r​e​ ​i​n​t​e​r​e​s​t​e​d​ ​i​n​ ​u​s​i​n​g​ ​i​t​,​ ​p​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​o​u​r​ ​s​a​l​e​s​ ​t​e​a​m​ ​a​t​:​ ​s​a​l​e​s​@​d​e​f​g​u​a​r​d​.​n​e​t + */ + enterpriseTierWarning: string } } sections: { @@ -11694,6 +11698,10 @@ export type TranslationFunctions = { * Service locations can't be used while location MFA is enabled. */ mfaWarning: () => LocalizedString + /** + * This feature requires an Enterprise-tier license. If you are interested in using it, please contact our sales team at: sales@defguard.net + */ + enterpriseTierWarning: () => LocalizedString } } sections: { diff --git a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx index 8968939c1..197115644 100644 --- a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx +++ b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx @@ -25,6 +25,7 @@ import useApi from '../../../shared/hooks/useApi'; import { useToaster } from '../../../shared/hooks/useToaster'; import { QueryKeys } from '../../../shared/queries'; import { + LicenseTier, LocationMfaMode, type Network, ServiceLocationMode, @@ -53,7 +54,18 @@ export const NetworkEditForm = () => { ); const queryClient = useQueryClient(); const { LL } = useI18nContext(); - const enterpriseEnabled = useAppStore((s) => s.appInfo?.license_info.enterprise); + const [licenseEnabled, licenseTier, isFreeLicense] = useAppStore( + (s) => [ + s.appInfo?.license_info.enterprise, + s.appInfo?.license_info.tier, + s.appInfo?.license_info.is_enterprise_free, + ], + shallow, + ); + const enterpriseLicenseEnabled = useMemo( + () => Boolean(isFreeLicense || licenseTier === LicenseTier.ENTERPRISE), + [licenseTier, isFreeLicense], + ); const { mutate } = useMutation({ mutationFn: editNetwork, @@ -397,7 +409,7 @@ export const NetworkEditForm = () => { displayValue: titleCase(val), })} /> - {!enterpriseEnabled && ( + {!licenseEnabled && (

{LL.networkConfiguration.form.helpers.aclFeatureDisabled()}

@@ -406,7 +418,7 @@ export const NetworkEditForm = () => { controller={{ control, name: 'acl_enabled' }} label={LL.networkConfiguration.form.fields.acl_enabled.label()} labelPlacement="right" - disabled={!enterpriseEnabled} + disabled={!licenseEnabled} /> { - {!mfaDisabled && ( + {!enterpriseLicenseEnabled ? ( -

{LL.networkConfiguration.form.helpers.serviceLocation.mfaWarning()}

+

+ {LL.networkConfiguration.form.helpers.serviceLocation.enterpriseTierWarning()} +

+ ) : ( + !mfaDisabled && ( + +

{LL.networkConfiguration.form.helpers.serviceLocation.mfaWarning()}

+
+ ) )} diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx index 993154b89..5cf16313b 100644 --- a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx +++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx @@ -23,7 +23,11 @@ import { useAppStore } from '../../../../shared/hooks/store/useAppStore.ts'; import useApi from '../../../../shared/hooks/useApi'; import { useToaster } from '../../../../shared/hooks/useToaster'; import { QueryKeys } from '../../../../shared/queries'; -import { LocationMfaMode, ServiceLocationMode } from '../../../../shared/types.ts'; +import { + LicenseTier, + LocationMfaMode, + ServiceLocationMode, +} from '../../../../shared/types.ts'; import { titleCase } from '../../../../shared/utils/titleCase'; import { trimObjectStrings } from '../../../../shared/utils/trimObjectStrings.ts'; import { Validate } from '../../../../shared/validators'; @@ -45,7 +49,18 @@ export const WizardNetworkConfiguration = () => { ); const wizardNetworkConfiguration = useWizardStore((state) => state.manualNetworkConfig); - const enterpriseEnabled = useAppStore((s) => s.appInfo?.license_info.enterprise); + const [licenseEnabled, licenseTier, isFreeLicense] = useAppStore( + (s) => [ + s.appInfo?.license_info.enterprise, + s.appInfo?.license_info.tier, + s.appInfo?.license_info.is_enterprise_free, + ], + shallow, + ); + const enterpriseLicenseEnabled = useMemo( + () => Boolean(isFreeLicense || licenseTier === LicenseTier.ENTERPRISE), + [licenseTier, isFreeLicense], + ); const toaster = useToaster(); const { LL } = useI18nContext(); @@ -290,7 +305,7 @@ export const WizardNetworkConfiguration = () => { displayValue: titleCase(group), })} /> - {!enterpriseEnabled && ( + {!licenseEnabled && (

{LL.networkConfiguration.form.helpers.aclFeatureDisabled()}

@@ -338,14 +353,22 @@ export const WizardNetworkConfiguration = () => { type="number" disabled={mfaDisabled} /> - {!mfaDisabled && ( + {!enterpriseLicenseEnabled ? ( -

{LL.networkConfiguration.form.helpers.serviceLocation.mfaWarning()}

+

+ {LL.networkConfiguration.form.helpers.serviceLocation.enterpriseTierWarning()} +

+ ) : ( + !mfaDisabled && ( + +

{LL.networkConfiguration.form.helpers.serviceLocation.mfaWarning()}

+
+ ) )} diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 86bf0baca..b438891d7 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -1433,9 +1433,15 @@ export type LicenseLimits = { wireguard_network: boolean; }; +export enum LicenseTier { + BUSINESS = 'Business', + ENTERPRISE = 'Enterprise', +} + export type LicenseInfo = { enterprise: boolean; limits_exceeded: LicenseLimits; any_limit_exceeded: boolean; is_enterprise_free: boolean; + tier?: LicenseTier; }; From b9815b3eb24d51bd9a29bf7248794079a8bf4fb2 Mon Sep 17 00:00:00 2001 From: Maciek <19913370+wojcik91@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:07:52 +0100 Subject: [PATCH 12/17] don't tag as latest automatically (#1749) --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1939c92b1..c37330f5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,6 @@ jobs: uses: ./.github/workflows/build-docker.yml with: tags: | - type=raw,value=latest type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha From 84c3be720b3fa6c0eb1c82a30abebaa648dc313a Mon Sep 17 00:00:00 2001 From: Maciek <19913370+wojcik91@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:09:31 +0100 Subject: [PATCH 13/17] disable default latest tag in docker action (#1751) --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c37330f5c..9a02f3b46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,9 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha + # Explicitly disable latest tag. It will be added otherwise. + flavor: | + latest=false build-docker-prerelease: # Only build tags with -, like v1.0.0-alpha From 9529d1dbb3d3577040a2ef0a3a15a401a7bc003f Mon Sep 17 00:00:00 2001 From: Maciek <19913370+wojcik91@users.noreply.github.com> Date: Tue, 16 Dec 2025 19:47:32 +0100 Subject: [PATCH 14/17] display license tier in settings (#1754) --- crates/defguard_core/src/enterprise/handlers/mod.rs | 3 ++- web/src/i18n/en/index.ts | 3 +++ web/src/i18n/i18n-types.ts | 12 ++++++++++++ .../components/LicenseSettings/LicenseSettings.tsx | 6 ++++++ .../components/LicenseSettings/styles.scss | 2 +- web/src/shared/types.ts | 1 + 6 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/defguard_core/src/enterprise/handlers/mod.rs b/crates/defguard_core/src/enterprise/handlers/mod.rs index 5686717e6..8083e7778 100644 --- a/crates/defguard_core/src/enterprise/handlers/mod.rs +++ b/crates/defguard_core/src/enterprise/handlers/mod.rs @@ -57,7 +57,8 @@ pub async fn check_enterprise_info(_admin: AdminRole, _session: SessionInfo) -> "valid_until": license.valid_until, "subscription": license.subscription, "expired": license.is_max_overdue(), - "limits_exceeded": counts.is_over_license_limits(license) + "limits_exceeded": counts.is_over_license_limits(license), + "tier": license.tier } ) }); diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 943137873..19f4cf9a2 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -1575,6 +1575,9 @@ Licensing information: [https://docs.defguard.net/enterprise/license](https://do validUntil: { label: 'Valid until', }, + licenseTier: { + label: 'License tier', + }, }, }, }, diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index c4fd69694..813e6609f 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -3813,6 +3813,12 @@ type RootTranslation = { */ label: string } + licenseTier: { + /** + * L​i​c​e​n​s​e​ ​t​i​e​r + */ + label: string + } } } } @@ -10575,6 +10581,12 @@ export type TranslationFunctions = { */ label: () => LocalizedString } + licenseTier: { + /** + * License tier + */ + label: () => LocalizedString + } } } } diff --git a/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/LicenseSettings.tsx b/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/LicenseSettings.tsx index 3fa9acfab..c1076a30f 100644 --- a/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/LicenseSettings.tsx +++ b/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/LicenseSettings.tsx @@ -109,6 +109,12 @@ export const LicenseSettings = ({ : '-'}

+
+ +

{enterpriseInfo.tier || '-'}

+
) : (

diff --git a/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/styles.scss b/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/styles.scss index 3d1ca3037..4f8ba8e01 100644 --- a/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/styles.scss +++ b/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/styles.scss @@ -25,7 +25,7 @@ #license-info { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; & > div { diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index b438891d7..b84158200 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -1148,6 +1148,7 @@ export type EnterpriseInfo = { subscription: boolean; // iso utc date valid_until: string; + tier: string; }; export interface Webhook { From db6ac01fda00a24512f43a76566ed4dbb83c4e4a Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Wed, 17 Dec 2025 10:21:22 +0100 Subject: [PATCH 15/17] cargo fmt --- crates/defguard_common/src/db/models/wireguard.rs | 3 +-- crates/defguard_core/src/enterprise/grpc/polling.rs | 5 +---- crates/defguard_core/src/enterprise/ldap/mod.rs | 4 +--- crates/defguard_core/src/grpc/gateway/mod.rs | 3 ++- crates/defguard_core/src/location_management/mod.rs | 11 ++++++++--- .../defguard_core/tests/integration/api/common/mod.rs | 1 - .../tests/integration/grpc/common/mod.rs | 1 - 7 files changed, 13 insertions(+), 15 deletions(-) diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index a27a77864..04c4de1db 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -1035,8 +1035,7 @@ impl WireguardNetwork { result.rows_affected(), ); Ok(()) - } - + } } // [`IpNetwork`] does not implement [`Default`] diff --git a/crates/defguard_core/src/enterprise/grpc/polling.rs b/crates/defguard_core/src/enterprise/grpc/polling.rs index 8f4fff15a..fef268fcb 100644 --- a/crates/defguard_core/src/enterprise/grpc/polling.rs +++ b/crates/defguard_core/src/enterprise/grpc/polling.rs @@ -6,10 +6,7 @@ use defguard_proto::proxy::{DeviceInfo, InstanceInfoRequest, InstanceInfoRespons use sqlx::PgPool; use tonic::Status; -use crate::{ - enterprise::is_business_license_active, - grpc::utils::build_device_config_response, -}; +use crate::{enterprise::is_business_license_active, grpc::utils::build_device_config_response}; pub struct PollingServer { pool: PgPool, diff --git a/crates/defguard_core/src/enterprise/ldap/mod.rs b/crates/defguard_core/src/enterprise/ldap/mod.rs index c5e2908d9..51962b7ee 100644 --- a/crates/defguard_core/src/enterprise/ldap/mod.rs +++ b/crates/defguard_core/src/enterprise/ldap/mod.rs @@ -18,15 +18,13 @@ use sync::{get_ldap_sync_status, is_ldap_desynced, set_ldap_sync_status}; use self::error::LdapError; use crate::enterprise::{ + is_business_license_active, ldap::model::{ extract_dn_path, ldap_sync_allowed_for_user, user_as_ldap_attrs, user_as_ldap_mod, user_from_searchentry, }, limits::update_counts, }; -use crate::{ - enterprise::{is_business_license_active}, -}; #[cfg(not(test))] pub mod client; diff --git a/crates/defguard_core/src/grpc/gateway/mod.rs b/crates/defguard_core/src/grpc/gateway/mod.rs index 0bcf75fd9..d4f68631d 100644 --- a/crates/defguard_core/src/grpc/gateway/mod.rs +++ b/crates/defguard_core/src/grpc/gateway/mod.rs @@ -126,7 +126,8 @@ pub struct GatewayServer { /// - Enterprise is enabled #[must_use] pub fn should_prevent_service_location_usage(location: &WireguardNetwork) -> bool { - location.service_location_mode != ServiceLocationMode::Disabled && !is_enterprise_license_active() + location.service_location_mode != ServiceLocationMode::Disabled + && !is_enterprise_license_active() } /// Utility struct encapsulating commonly extracted metadata fields during gRPC communication. diff --git a/crates/defguard_core/src/location_management/mod.rs b/crates/defguard_core/src/location_management/mod.rs index 3d6ce5b52..8ff7a4564 100644 --- a/crates/defguard_core/src/location_management/mod.rs +++ b/crates/defguard_core/src/location_management/mod.rs @@ -3,9 +3,14 @@ use std::{collections::HashMap, net::IpAddr}; use defguard_common::{ csv::AsCsv, db::{ + Id, models::{ - device::{DeviceInfo, WireguardNetworkDevice}, user::User, wireguard::MappedDevice, Device, DeviceNetworkInfo, DeviceType, ModelError, WireguardNetwork, WireguardNetworkError - }, Id + Device, DeviceNetworkInfo, DeviceType, ModelError, WireguardNetwork, + WireguardNetworkError, + device::{DeviceInfo, WireguardNetworkDevice}, + user::User, + wireguard::MappedDevice, + }, }, }; use sqlx::PgConnection; @@ -13,7 +18,7 @@ use thiserror::Error; use tokio::sync::broadcast::Sender; use crate::{ - enterprise::firewall::{try_get_location_firewall_config, FirewallError}, + enterprise::firewall::{FirewallError, try_get_location_firewall_config}, grpc::gateway::{events::GatewayEvent, send_multiple_wireguard_events}, wg_config::ImportedDevice, }; diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 992430bba..6e6e11c6b 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -17,7 +17,6 @@ use defguard_common::{ use defguard_core::{ auth::failed_login::FailedLoginMap, build_webapp, - db::AppEvent, db::{AppEvent, Device, GatewayEvent, User, UserDetails, WireguardNetwork}, enterprise::license::{License, LicenseTier, set_cached_license}, events::ApiEvent, diff --git a/crates/defguard_core/tests/integration/grpc/common/mod.rs b/crates/defguard_core/tests/integration/grpc/common/mod.rs index 507063468..07fd8b8c3 100644 --- a/crates/defguard_core/tests/integration/grpc/common/mod.rs +++ b/crates/defguard_core/tests/integration/grpc/common/mod.rs @@ -4,7 +4,6 @@ use axum::http::Uri; use defguard_common::db::models::settings::initialize_current_settings; use defguard_core::{ auth::failed_login::FailedLoginMap, - db::AppEvent, db::{AppEvent, GatewayEvent}, enterprise::license::{License, LicenseTier, set_cached_license}, events::GrpcEvent, From e3ad4aa643fa93fb5edd39371d4a441994873085 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Wed, 17 Dec 2025 10:32:29 +0100 Subject: [PATCH 16/17] fix test imports --- crates/defguard_core/tests/integration/api/common/mod.rs | 2 +- crates/defguard_core/tests/integration/grpc/common/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 6e6e11c6b..f74a24da4 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -17,7 +17,7 @@ use defguard_common::{ use defguard_core::{ auth::failed_login::FailedLoginMap, build_webapp, - db::{AppEvent, Device, GatewayEvent, User, UserDetails, WireguardNetwork}, + db::AppEvent, enterprise::license::{License, LicenseTier, set_cached_license}, events::ApiEvent, grpc::{ diff --git a/crates/defguard_core/tests/integration/grpc/common/mod.rs b/crates/defguard_core/tests/integration/grpc/common/mod.rs index 07fd8b8c3..c48750a8a 100644 --- a/crates/defguard_core/tests/integration/grpc/common/mod.rs +++ b/crates/defguard_core/tests/integration/grpc/common/mod.rs @@ -4,7 +4,7 @@ use axum::http::Uri; use defguard_common::db::models::settings::initialize_current_settings; use defguard_core::{ auth::failed_login::FailedLoginMap, - db::{AppEvent, GatewayEvent}, + db::AppEvent, enterprise::license::{License, LicenseTier, set_cached_license}, events::GrpcEvent, grpc::{ From a0d685f9c71de4f64f192625604ee6aaca22f3f5 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Wed, 17 Dec 2025 10:41:54 +0100 Subject: [PATCH 17/17] fix cargo deny --- deny.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deny.toml b/deny.toml index 49991da9e..df0ff4378 100644 --- a/deny.toml +++ b/deny.toml @@ -133,6 +133,9 @@ exceptions = [ { allow = [ "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_event_logger" }, + { allow = [ + "AGPL-3.0-only", "AGPL-3.0-or-later", + ], crate = "defguard_session_manager" }, { allow = [ "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_version" },