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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1939c92b1..9a02f3b46 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,10 +23,12 @@ jobs:
uses: ./.github/workflows/build-docker.yml
with:
tags: |
- type=raw,value=latest
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
diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml
new file mode 100644
index 000000000..2c1b81327
--- /dev/null
+++ b/.github/workflows/test-web.yml
@@ -0,0 +1,39 @@
+on:
+ push:
+ branches:
+ - main
+ - dev
+ - "release/**"
+ paths-ignore:
+ - "*.md"
+ - "LICENSE"
+ pull_request:
+ branches:
+ - main
+ - dev
+ - "release/**"
+ paths-ignore:
+ - "*.md"
+ - "LICENSE"
+
+permissions:
+ contents: read
+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/.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/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 08384886f..8575ccf38 100644
--- a/crates/defguard_core/src/auth/mod.rs
+++ b/crates/defguard_core/src/auth/mod.rs
@@ -22,7 +22,7 @@ use defguard_common::db::{
use crate::{
appstate::AppState,
- 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,
};
@@ -40,7 +40,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/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 f2e85ced8..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,16 +1,16 @@
-use sqlx::{PgExecutor, query, query_as};
+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)))]
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(),
}
}
}
@@ -35,11 +35,12 @@ 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, \
- 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/enterprise/directory_sync/mod.rs b/crates/defguard_core/src/enterprise/directory_sync/mod.rs
index e46eb6108..3c770b61b 100644
--- a/crates/defguard_core/src/enterprise/directory_sync/mod.rs
+++ b/crates/defguard_core/src/enterprise/directory_sync/mod.rs
@@ -14,12 +14,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::{
enterprise::{
db::models::openid_provider::DirectorySyncUserBehavior,
@@ -390,7 +390,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(());
}
@@ -415,7 +415,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(());
}
@@ -975,7 +975,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 fc446d7a4..67d8a142e 100644
--- a/crates/defguard_core/src/enterprise/firewall/mod.rs
+++ b/crates/defguard_core/src/enterprise/firewall/mod.rs
@@ -24,7 +24,7 @@ use super::{
};
use crate::enterprise::{
db::models::{acl::AliasKind, snat::UserSnatBinding},
- is_enterprise_enabled,
+ is_business_license_active,
};
#[derive(Debug, thiserror::Error)]
@@ -905,7 +905,7 @@ pub async fn try_get_location_firewall_config(
conn: &mut PgConnection,
) -> Result
+
+
+
{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/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 8d416e221..5cf16313b 100644
--- a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx
+++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx
@@ -23,14 +23,14 @@ 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 {
- validateIpList,
- validateIpOrDomain,
- validateIpOrDomainList,
-} from '../../../../shared/validators';
+import { Validate } from '../../../../shared/validators';
import { useWizardStore } from '../../hooks/useWizardStore';
import { DividerHeader } from './components/DividerHeader.tsx';
@@ -49,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();
@@ -111,31 +122,52 @@ 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), LL.form.error.endpoint()),
+ .refine(
+ (val) =>
+ Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain], true),
+ LL.form.error.endpoint(),
+ ),
port: z
.number({
invalid_type_error: LL.form.error.invalid(),
})
.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({
@@ -273,7 +305,7 @@ export const WizardNetworkConfiguration = () => {
displayValue: titleCase(group),
})}
/>
- {!enterpriseEnabled && (
+ {!licenseEnabled && (
{LL.networkConfiguration.form.helpers.aclFeatureDisabled()}
@@ -321,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/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 3c181cb11..b84158200 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;
};
};
@@ -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;
};
@@ -1142,6 +1148,7 @@ export type EnterpriseInfo = {
subscription: boolean;
// iso utc date
valid_until: string;
+ tier: string;
};
export interface Webhook {
@@ -1427,9 +1434,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;
};
diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts
index 77df62d88..23b24bbfc 100644
--- a/web/src/shared/validators.ts
+++ b/web/src/shared/validators.ts
@@ -1,7 +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;
@@ -18,85 +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 => {
- return (
- (allowIPv6 && validateIPv6(val, allowMask)) ||
- validateIPv4(val, allowMask) ||
- patternValidDomain.test(val)
- );
-};
-
-// Returns false when invalid
-export const validateIpList = (
- val: string,
- splitWith = ',',
- allowMasks = false,
-): boolean => {
- return val
- .replace(' ', '')
- .split(splitWith)
- .every((el) => {
- 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 ipaddr.IPv4.isValid(ip);
-};
-
-export const validateIPv6 = (ip: string, allowMask = false): boolean => {
- if (allowMask) {
+ return true;
+ },
+ CIDRv6: (ip: string): boolean => {
if (ip.endsWith('/0')) {
return false;
}
- if (ip.includes('/')) {
- return ipaddr.IPv6.isValidCIDR(ip);
+ 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;
+ }
+ 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 (items.length > 1 && !allowList) {
+ return false;
+ }
+
+ for (const item of items) {
+ let valid = false;
+ for (const validator of validators) {
+ if (validator(item)) {
+ valid = true;
+ break;
+ }
+ }
+ if (!valid) {
+ return false;
+ }
+ }
+
+ 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;
+ }
+ 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'),
+ },
+ },
+});