= RwLock::new(Counts {
+ user: 0,
+ device: 0,
+ wireguard_network: 0,
+});
+
+fn set_counts(new_counts: Counts) {
+ *COUNTS
+ .write()
+ .expect("Failed to acquire lock on the enterprise limit counts.") = new_counts;
+}
+
+pub(crate) fn get_counts() -> RwLockReadGuard<'static, Counts> {
+ COUNTS
+ .read()
+ .expect("Failed to acquire lock on the enterprise limit counts.")
+}
+
+/// Update the counts of users, devices, and wireguard networks stored in the memory.
+// TODO: Use it with database triggers when they are implemented
+pub async fn update_counts(pool: &PgPool) -> Result<(), SqlxError> {
+ debug!("Updating device, user, and wireguard network counts.");
+ let counts = query_as!(
+ Counts,
+ "SELECT \
+ (SELECT count(*) FROM \"user\") \"user!\", \
+ (SELECT count(*) FROM device) \"device!\", \
+ (SELECT count(*) FROM wireguard_network) \"wireguard_network!\"
+ "
+ )
+ .fetch_one(pool)
+ .await?;
+
+ set_counts(counts);
+ debug!(
+ "Updated device, user, and wireguard network counts stored in memory, new counts: {:?}",
+ get_counts()
+ );
+
+ Ok(())
+}
+
+impl Counts {
+ pub(crate) fn is_over_limit(&self) -> bool {
+ self.user > 5 || self.device > 10 || self.wireguard_network > 1
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn test_counts() {
+ let counts = Counts {
+ user: 1,
+ device: 2,
+ wireguard_network: 3,
+ };
+
+ set_counts(counts);
+
+ let counts = get_counts();
+
+ assert_eq!(counts.user, 1);
+ assert_eq!(counts.device, 2);
+ assert_eq!(counts.wireguard_network, 3);
+ }
+
+ #[test]
+ fn test_is_over_limit() {
+ // User limit
+ {
+ let counts = Counts {
+ user: 6,
+ device: 1,
+ wireguard_network: 1,
+ };
+ set_counts(counts);
+ let counts = get_counts();
+ assert!(counts.is_over_limit());
+ }
+
+ // Device limit
+ {
+ let counts = Counts {
+ user: 1,
+ device: 11,
+ wireguard_network: 1,
+ };
+ set_counts(counts);
+ let counts = get_counts();
+ assert!(counts.is_over_limit());
+ }
+
+ // Wireguard network limit
+ {
+ let counts = Counts {
+ user: 1,
+ device: 1,
+ wireguard_network: 2,
+ };
+ set_counts(counts);
+ let counts = get_counts();
+ assert!(counts.is_over_limit());
+ }
+
+ // No limit
+ {
+ let counts = Counts {
+ user: 1,
+ device: 1,
+ wireguard_network: 1,
+ };
+ set_counts(counts);
+ let counts = get_counts();
+ assert!(!counts.is_over_limit());
+ }
+
+ // All limits
+ {
+ let counts = Counts {
+ user: 6,
+ device: 11,
+ wireguard_network: 2,
+ };
+ set_counts(counts);
+ let counts = get_counts();
+ assert!(counts.is_over_limit());
+ }
+ }
+}
diff --git a/src/enterprise/mod.rs b/src/enterprise/mod.rs
index dadca5b4c..3c4c5adfa 100644
--- a/src/enterprise/mod.rs
+++ b/src/enterprise/mod.rs
@@ -2,3 +2,27 @@ pub mod db;
pub mod grpc;
pub mod handlers;
pub mod license;
+pub mod limits;
+use license::{get_cached_license, validate_license};
+use limits::get_counts;
+
+pub(crate) fn needs_enterprise_license() -> bool {
+ get_counts().is_over_limit()
+}
+
+pub(crate) fn is_enterprise_enabled() -> bool {
+ debug!("Checking if enterprise is enabled");
+ match needs_enterprise_license() {
+ true => {
+ debug!("User is over limit, checking his license");
+ let license = get_cached_license();
+ let validation_result = validate_license(license.as_ref());
+ debug!("License validation result: {:?}", validation_result);
+ validation_result.is_ok()
+ }
+ false => {
+ debug!("User is not over limit, allowing enterprise features");
+ true
+ }
+ }
+}
diff --git a/src/grpc/mod.rs b/src/grpc/mod.rs
index ea2a26cbd..802eb398c 100644
--- a/src/grpc/mod.rs
+++ b/src/grpc/mod.rs
@@ -48,9 +48,8 @@ use crate::{
auth::failed_login::FailedLoginMap,
db::{AppEvent, Id, Settings},
enterprise::{
- db::models::enterprise_settings::EnterpriseSettings,
- grpc::polling::PollingServer,
- license::{get_cached_license, validate_license},
+ db::models::enterprise_settings::EnterpriseSettings, grpc::polling::PollingServer,
+ is_enterprise_enabled,
},
handlers::mail::send_gateway_disconnected_email,
mail::Mail,
@@ -679,7 +678,7 @@ impl InstanceInfo {
proxy_url: config.enrollment_url.clone(),
username: username.into(),
disable_all_traffic: enterprise_settings.disable_all_traffic,
- enterprise_enabled: validate_license(get_cached_license().as_ref()).is_ok(),
+ enterprise_enabled: is_enterprise_enabled(),
}
}
}
diff --git a/src/handlers/app_info.rs b/src/handlers/app_info.rs
index a8bc4294c..4cc3cb642 100644
--- a/src/handlers/app_info.rs
+++ b/src/handlers/app_info.rs
@@ -6,7 +6,7 @@ use crate::{
appstate::AppState,
auth::SessionInfo,
db::{Settings, WireguardNetwork},
- enterprise::license::{get_cached_license, validate_license},
+ enterprise::is_enterprise_enabled,
};
/// Additional information about core state.
@@ -25,8 +25,7 @@ pub(crate) async fn get_app_info(
) -> ApiResult {
let networks = WireguardNetwork::all(&appstate.pool).await?;
let settings = Settings::get_settings(&appstate.pool).await?;
- let license = get_cached_license();
- let enterprise = validate_license((license).as_ref()).is_ok();
+ let enterprise = is_enterprise_enabled();
let res = AppInfo {
network_present: !networks.is_empty(),
smtp_enabled: settings.smtp_configured(),
diff --git a/src/handlers/user.rs b/src/handlers/user.rs
index 51e88154f..9365d61ea 100644
--- a/src/handlers/user.rs
+++ b/src/handlers/user.rs
@@ -22,6 +22,7 @@ use crate::{
AppEvent, GatewayEvent, MFAMethod, OAuth2AuthorizedApp, Settings, User, UserDetails,
UserInfo, Wallet, WebAuthn, WireguardNetwork,
},
+ enterprise::limits::update_counts,
error::WebError,
ldap::utils::{ldap_add_user, ldap_change_password, ldap_delete_user, ldap_modify_user},
mail::Mail,
@@ -336,6 +337,7 @@ pub async fn add_user(
)
.save(&appstate.pool)
.await?;
+ update_counts(&appstate.pool).await?;
if let Some(password) = user_data.password {
let _result = ldap_add_user(&appstate.pool, &user, &password).await;
@@ -734,6 +736,7 @@ pub async fn delete_user(
let _result = ldap_delete_user(&mut *transaction, &username).await;
appstate.trigger_action(AppEvent::UserDeleted(username.clone()));
transaction.commit().await?;
+ update_counts(&appstate.pool).await?;
info!("User {} deleted user {}", session.user.username, &username);
Ok(ApiResponse::default())
diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs
index 149433e2d..13fad84dd 100644
--- a/src/handlers/wireguard.rs
+++ b/src/handlers/wireguard.rs
@@ -29,7 +29,7 @@ use crate::{
},
AddDevice, Device, GatewayEvent, Id, WireguardNetwork,
},
- enterprise::handlers::CanManageDevices,
+ enterprise::{handlers::CanManageDevices, limits::update_counts},
grpc::GatewayMap,
handlers::mail::send_new_device_added_email,
server_config,
@@ -135,6 +135,7 @@ pub async fn create_network(
"User {} created WireGuard network {network_name}",
session.user.username
);
+ update_counts(&appstate.pool).await?;
Ok(ApiResponse {
json: json!(network),
@@ -218,6 +219,7 @@ pub async fn delete_network(
"User {} deleted WireGuard network {network_id}",
session.user.username,
);
+ update_counts(&appstate.pool).await?;
Ok(ApiResponse::default())
}
@@ -374,6 +376,8 @@ pub async fn import_network(
info!("Imported network {network} with {} devices", devices.len());
+ update_counts(&appstate.pool).await?;
+
Ok(ApiResponse {
json: json!(ImportedNetworkData { network, devices }),
status: StatusCode::CREATED,
@@ -419,6 +423,7 @@ pub async fn add_user_devices(
"User {} mapped {device_count} devices for {network_id} network",
user.username,
);
+ update_counts(&appstate.pool).await?;
Ok(ApiResponse {
json: json!({}),
@@ -592,6 +597,8 @@ pub async fn add_device(
let result = AddDeviceResult { configs, device };
+ update_counts(&appstate.pool).await?;
+
Ok(ApiResponse {
json: json!(result),
status: StatusCode::CREATED,
@@ -762,6 +769,7 @@ pub async fn delete_device(
));
device.delete(&appstate.pool).await?;
info!("User {} deleted device {device_id}", session.user.username);
+ update_counts(&appstate.pool).await?;
Ok(ApiResponse::default())
}
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 394bedce5..c0eef8a4e 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -9,12 +9,14 @@ use defguard::{
db::{init_db, AppEvent, GatewayEvent, Id, User, UserDetails},
enterprise::license::{set_cached_license, License},
grpc::{GatewayMap, WorkerState},
+ handlers::Auth,
headers::create_user_agent_parser,
mail::Mail,
SERVER_CONFIG,
};
use reqwest::{header::HeaderName, StatusCode};
use secrecy::ExposeSecret;
+use serde_json::json;
use sqlx::{postgres::PgConnectOptions, query, types::Uuid, PgPool};
use tokio::sync::{
broadcast::{self, Receiver},
@@ -184,3 +186,41 @@ pub async fn fetch_user_details(client: &TestClient, username: &str) -> UserDeta
assert_eq!(response.status(), StatusCode::OK);
response.json().await
}
+
+pub async fn exceed_enterprise_limits(client: &TestClient) {
+ let auth = Auth::new("admin", "pass123");
+ client.post("/api/v1/auth").json(&auth).send().await;
+ client
+ .post("/api/v1/network")
+ .json(&json!({
+ "name": "network1",
+ "address": "10.1.1.1/24",
+ "port": 55555,
+ "endpoint": "192.168.4.14",
+ "allowed_ips": "10.1.1.0/24",
+ "dns": "1.1.1.1",
+ "allowed_groups": [],
+ "mfa_enabled": false,
+ "keepalive_interval": 25,
+ "peer_disconnect_threshold": 180
+ }))
+ .send()
+ .await;
+
+ client
+ .post("/api/v1/network")
+ .json(&json!({
+ "name": "network2",
+ "address": "10.1.1.1/24",
+ "port": 55555,
+ "endpoint": "192.168.4.14",
+ "allowed_ips": "10.1.1.0/24",
+ "dns": "1.1.1.1",
+ "allowed_groups": [],
+ "mfa_enabled": false,
+ "keepalive_interval": 25,
+ "peer_disconnect_threshold": 180
+ }))
+ .send()
+ .await;
+}
diff --git a/tests/enterprise_settings.rs b/tests/enterprise_settings.rs
index 66d63f5c3..78fb11496 100644
--- a/tests/enterprise_settings.rs
+++ b/tests/enterprise_settings.rs
@@ -1,5 +1,6 @@
mod common;
+use common::exceed_enterprise_limits;
use defguard::{
enterprise::{
db::models::enterprise_settings::EnterpriseSettings,
@@ -35,6 +36,8 @@ async fn test_only_enterprise_can_modify() {
let response = client.post("/api/v1/auth").json(&auth).send().await;
assert_eq!(response.status(), StatusCode::OK);
+ exceed_enterprise_limits(&client).await;
+
// unset the license
let license = get_cached_license().clone();
set_cached_license(None);
@@ -75,6 +78,8 @@ async fn test_admin_devices_management_is_enforced() {
let response = client.post("/api/v1/auth").json(&auth).send().await;
assert_eq!(response.status(), StatusCode::OK);
+ exceed_enterprise_limits(&client).await;
+
// create network
let response = client
.post("/api/v1/network")
@@ -152,6 +157,8 @@ async fn test_regular_user_device_management() {
let response = client.post("/api/v1/auth").json(&auth).send().await;
assert_eq!(response.status(), StatusCode::OK);
+ exceed_enterprise_limits(&client).await;
+
// create network
let response = client
.post("/api/v1/network")
diff --git a/tests/openid_login.rs b/tests/openid_login.rs
index beb62c7e5..7c9d8def1 100644
--- a/tests/openid_login.rs
+++ b/tests/openid_login.rs
@@ -1,4 +1,5 @@
use chrono::{Duration, Utc};
+use common::exceed_enterprise_limits;
use defguard::{
config::DefGuardConfig,
enterprise::{
@@ -33,6 +34,8 @@ async fn test_openid_providers() {
let response = client.post("/api/v1/auth").json(&auth).send().await;
assert_eq!(response.status(), StatusCode::OK);
+ exceed_enterprise_limits(&client).await;
+
let provider_data = AddProviderData::new(
"test",
"https://accounts.google.com",
diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts
index ed010dc60..cf73b36bd 100644
--- a/web/src/i18n/en/index.ts
+++ b/web/src/i18n/en/index.ts
@@ -1089,6 +1089,8 @@ const en: BaseTranslation = {
licenseInfo: {
title: 'License information',
noLicense: 'No license',
+ licenseNotRequired:
+ "You have access to this enterprise feature, as you haven't exceeded any of the usage limits yet. Check the documentation for more information.
",
types: {
subscription: {
label: 'Subscription',
diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts
index c70aa7afe..def7dc3d1 100644
--- a/web/src/i18n/i18n-types.ts
+++ b/web/src/i18n/i18n-types.ts
@@ -2649,6 +2649,10 @@ type RootTranslation = {
* No license
*/
noLicense: string
+ /**
+ * <p>You have access to this enterprise feature, as you haven't exceeded any of the usage limits yet. Check the <a href='https://docs.defguard.net/enterprise/license'>documentation</a> for more information.</p>
+ */
+ licenseNotRequired: string
types: {
subscription: {
/**
@@ -6903,6 +6907,10 @@ export type TranslationFunctions = {
* No license
*/
noLicense: () => LocalizedString
+ /**
+ * You have access to this enterprise feature, as you haven't exceeded any of the usage limits yet. Check the documentation for more information.
+ */
+ licenseNotRequired: () => LocalizedString
types: {
subscription: {
/**
diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts
index b367c0c91..84d4e03d1 100644
--- a/web/src/i18n/pl/index.ts
+++ b/web/src/i18n/pl/index.ts
@@ -1078,6 +1078,8 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe
licenseInfo: {
title: 'Informacje o licencji',
noLicense: 'Brak licencji',
+ licenseNotRequired:
+ "Posiadasz dostęp do tej funkcji enterprise, ponieważ nie przekroczyłeś jeszcze żadnych limitów. Sprawdź dokumentację, aby uzyskać więcej informacji.
",
types: {
subscription: {
label: 'Subskrypcja',
diff --git a/web/src/pages/settings/components/EnterpriseSettings/EnterpriseSettings.tsx b/web/src/pages/settings/components/EnterpriseSettings/EnterpriseSettings.tsx
index af50d0056..dda18754c 100644
--- a/web/src/pages/settings/components/EnterpriseSettings/EnterpriseSettings.tsx
+++ b/web/src/pages/settings/components/EnterpriseSettings/EnterpriseSettings.tsx
@@ -1,4 +1,7 @@
+import parse from 'html-react-parser';
+
import { useI18nContext } from '../../../../i18n/i18n-react';
+import { BigInfoBox } from '../../../../shared/defguard-ui/components/Layout/BigInfoBox/BigInfoBox';
import { useAppStore } from '../../../../shared/hooks/store/useAppStore';
import { EnterpriseForm } from './components/EnterpriseForm';
@@ -26,6 +29,13 @@ export const EnterpriseSettings = () => {
)}
+ {!enterpriseStatus?.needs_license && !enterpriseStatus?.license_info && (
+
+
+
+ )}
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 da16ed7af..2c06f99e0 100644
--- a/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/LicenseSettings.tsx
+++ b/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/LicenseSettings.tsx
@@ -187,7 +187,9 @@ export const LicenseSettings = () => {
) : (
- {LL.settingsPage.license.licenseInfo.noLicense()}
+ <>
+ {LL.settingsPage.license.licenseInfo.noLicense()}
+ >
)}
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 f874bb076..a14d6b109 100644
--- a/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/styles.scss
+++ b/web/src/pages/settings/components/GlobalSettings/components/LicenseSettings/styles.scss
@@ -44,3 +44,7 @@
#no-license {
text-align: center;
}
+
+#license-not-required {
+ text-align: center;
+}
diff --git a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx
index 8ba04f5bf..37a3f1a0f 100644
--- a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx
+++ b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx
@@ -1,6 +1,9 @@
import './style.scss';
+import parse from 'html-react-parser';
+
import { useI18nContext } from '../../../../i18n/i18n-react';
+import { BigInfoBox } from '../../../../shared/defguard-ui/components/Layout/BigInfoBox/BigInfoBox';
import { useAppStore } from '../../../../shared/hooks/store/useAppStore';
import { OpenIdGeneralSettings } from './components/OpenIdGeneralSettings';
import { OpenIdSettingsForm } from './components/OpenIdSettingsForm';
@@ -30,6 +33,13 @@ export const OpenIdSettings = () => {
)}
+ {!enterpriseStatus?.needs_license && !enterpriseStatus?.license_info && (
+
+
+
+ )}
diff --git a/web/src/pages/settings/style.scss b/web/src/pages/settings/style.scss
index a76e77987..7677dc130 100644
--- a/web/src/pages/settings/style.scss
+++ b/web/src/pages/settings/style.scss
@@ -105,7 +105,6 @@
& > .left,
& > .right {
- grid-row: 1;
width: 100%;
max-width: 750px;
display: flex;
@@ -114,4 +113,9 @@
}
}
}
+
+ .license-not-required-container {
+ grid-column: 1 / -1;
+ width: 100%;
+ }
}
diff --git a/web/src/pages/users/UsersOverview/components/UsersList/components/UsersListGroups.tsx b/web/src/pages/users/UsersOverview/components/UsersList/components/UsersListGroups.tsx
index 8f49fd9d4..7ec85a908 100644
--- a/web/src/pages/users/UsersOverview/components/UsersList/components/UsersListGroups.tsx
+++ b/web/src/pages/users/UsersOverview/components/UsersList/components/UsersListGroups.tsx
@@ -88,7 +88,7 @@ export const UsersListGroups = ({ groups }: Props) => {
>
{displayGroups.map((g, index) => (
-
+
))}
{enabledModal && (
diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui
index 52a2f6d9b..b61bef8c8 160000
--- a/web/src/shared/defguard-ui
+++ b/web/src/shared/defguard-ui
@@ -1 +1 @@
-Subproject commit 52a2f6d9bf70d5cb497467f1caf4aa7a36d5d910
+Subproject commit b61bef8c893b4a27f62a3463d847274591520398
diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts
index fd02238e0..384a4c824 100644
--- a/web/src/shared/types.ts
+++ b/web/src/shared/types.ts
@@ -884,6 +884,7 @@ export type EnterpriseStatus = {
enabled: boolean;
// If there is no license, there is no license info
license_info?: LicenseInfo;
+ needs_license: boolean;
};
export interface Webhook {
From 8baf58ef86ff32c577b0ca4c2a7c2051e350d93b Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 13 Nov 2024 18:52:48 +0100
Subject: [PATCH 3/3] Fix network setup wizard IP input / e2e tests (#854)
* fix network wizard
* fix tests
---
.../WizardNetworkConfiguration.tsx | 36 +++++++++++++++----
1 file changed, 30 insertions(+), 6 deletions(-)
diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx
index cce80f6d5..7c7f734ab 100644
--- a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx
+++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx
@@ -2,6 +2,7 @@ import './style.scss';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
+import ipaddr from 'ipaddr.js';
import { useEffect, useMemo, useRef, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -20,7 +21,7 @@ import { QueryKeys } from '../../../../shared/queries';
import { ModifyNetworkRequest } from '../../../../shared/types';
import { titleCase } from '../../../../shared/utils/titleCase';
import { trimObjectStrings } from '../../../../shared/utils/trimObjectStrings.ts';
-import { validateIpOrDomainList, validateIPv4 } from '../../../../shared/validators';
+import { validateIpOrDomainList } from '../../../../shared/validators';
import { useWizardStore } from '../../hooks/useWizardStore';
type FormInputs = ModifyNetworkRequest['network'];
@@ -91,13 +92,36 @@ export const WizardNetworkConfiguration = () => {
if (!netmaskPresent) {
return false;
}
- const ipValid = validateIPv4(value, true);
- if (ipValid) {
- const host = value.split('.')[3].split('/')[0];
- if (host === '0') return false;
+ const ipValid = ipaddr.isValidCIDR(value);
+ if (!ipValid) {
+ return false;
+ }
+ const [address] = ipaddr.parseCIDR(value);
+ if (address.kind() === 'ipv6') {
+ const networkAddress = ipaddr.IPv6.networkAddressFromCIDR(value);
+ const broadcastAddress = ipaddr.IPv6.broadcastAddressFromCIDR(value);
+ if (
+ (address as ipaddr.IPv6).toNormalizedString() ===
+ networkAddress.toNormalizedString() ||
+ (address as ipaddr.IPv6).toNormalizedString() ===
+ broadcastAddress.toNormalizedString()
+ ) {
+ return false;
+ }
+ } else {
+ const networkAddress = ipaddr.IPv4.networkAddressFromCIDR(value);
+ const broadcastAddress = ipaddr.IPv4.broadcastAddressFromCIDR(value);
+ if (
+ (address as ipaddr.IPv4).toNormalizedString() ===
+ networkAddress.toNormalizedString() ||
+ (address as ipaddr.IPv4).toNormalizedString() ===
+ broadcastAddress.toNormalizedString()
+ ) {
+ return false;
+ }
}
return ipValid;
- }),
+ }, LL.form.error.addressNetmask()),
endpoint: z.string().min(1, LL.form.error.required()),
port: z
.number({