From 0d5805817a441866497164c26473ac4874516d10 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Tue, 12 Nov 2024 13:43:20 +0100 Subject: [PATCH 1/3] Fix ipv6 input and database adminid constraints (#851) * fix building docs * add libssl-dev dependency * Update README.md * add pkg-config dependency * add curl dependency * Update README.md * Update README.md * Update README.md * add on delete cascade to adminid in the token table * allow ipv6 input --------- Co-authored-by: Robert Olejnik --- .github/workflows/docs.yml | 2 +- README.md | 37 ++++++++------- .../20241108110157_add_on_delete.down.sql | 2 + .../20241108110157_add_on_delete.up.sql | 2 + web/package.json | 1 + web/pnpm-lock.yaml | 9 ++++ web/src/i18n/en/index.ts | 1 + web/src/i18n/i18n-types.ts | 8 ++++ web/src/i18n/pl/index.ts | 3 +- .../NetworkEditForm/NetworkEditForm.tsx | 47 ++++++++++++++----- .../SmtpSettingsForm/SmtpSettingsForm.tsx | 2 +- .../WizardNetworkConfiguration.tsx | 4 +- web/src/shared/patterns.ts | 3 -- web/src/shared/validators.ts | 47 +++++++++++++------ 14 files changed, 116 insertions(+), 52 deletions(-) create mode 100644 migrations/20241108110157_add_on_delete.down.sql create mode 100644 migrations/20241108110157_add_on_delete.up.sql diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 74f8db866..9f8796cb9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,7 +19,7 @@ jobs: container: rust:1-slim steps: - name: Install packages - run: apt-get update && apt install -y git protobuf-compiler libssl-dev + run: apt-get update && apt install -y git protobuf-compiler libssl-dev pkg-config curl - name: Checkout uses: actions/checkout@v4 diff --git a/README.md b/README.md index 61220a89f..81354e337 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,13 @@ GitHub commits since latest release

-[Website](https://defguard.net) | [Getting Started](https://docs.defguard.net/#what-is-defguard) | [Features](https://github.com/defguard/defguard#features) | [Roadmap](https://github.com/orgs/defguard/projects/5) | [Support ❤](https://github.com/defguard/defguard#support-) +[Website](https://defguard.net) | [Getting Started](https://docs.defguard.net/#what-is-defguard) | [Features](https://github.com/defguard/defguard#features) | [Roadmap](https://github.com/orgs/defguard/projects/5) | [Support ❤](https://github.com/defguard/defguard#support) -## Enterprise features are here! - -🛑 We encourge to test the [pre-release](https://docs.defguard.net/admin-and-features/setting-up-your-instance/pre-production-and-development-releases) of the new **Open Source Open Core** & **Enterprise features** (like external OpenID (Google/Microsoft/Custom), real time client sync and more!) published! 🛑 - -All currently available enterprise features are in [enterprise documentation section](https://docs.defguard.net/enterprise/all-enteprise-features) as well as information about [enterprise license](https://docs.defguard.net/enterprise/license). -### Unique value proposition +### Comprehensive Access Control -- **Comprehensive [WireGuard® 2FA/MFA](https://docs.defguard.net/admin-and-features/wireguard/multi-factor-authentication-mfa-2fa/architecture)** - not 2FA to "access application" like most solutions +- **[WireGuard® VPN with 2FA/MFA](https://docs.defguard.net/admin-and-features/wireguard/multi-factor-authentication-mfa-2fa/architecture)** - not 2FA to "access application" like most solutions - The only solution with [automatic and real-time synchronization](https://docs.defguard.net/enterprise/automatic-real-time-desktop-client-configuration) for users' desktop client settings (including all VPNs/locations). - Control users [ability to manage devices and VPN options](https://docs.defguard.net/enterprise/behavior-customization) - [Integrated SSO based on OpenID Connect](https://docs.defguard.net/admin-and-features/openid-connect): @@ -31,7 +26,9 @@ All currently available enterprise features are in [enterprise documentation sec - Built on WireGuard® protocol which is faster than IPSec, and significantly faster than OpenVPN - Built with Rust for speed and security -See below [full list of features](https://github.com/defguard/defguard#features) +See: +- [full list of features](https://github.com/defguard/defguard#features) +- [enterprise only features](https://docs.defguard.net/enterprise/all-enteprise-features) #### Video introduction @@ -65,6 +62,8 @@ Better quality video can [be viewed here](https://github.com/DefGuard/docs/raw/d [Desktop client](https://github.com/DefGuard/client): - **2FA / Multi-Factor Authentication** with TOTP or email based tokens & WireGuard PSK +- [automatic and real-time synchronization](https://docs.defguard.net/enterprise/automatic-real-time-desktop-client-configuration) for users' desktop client settings (including all VPNs/locations). +- Control users [ability to manage devices and VPN options](https://docs.defguard.net/enterprise/behavior-customization) - Defguard instances as well as **any WireGuard tunnel** - just import your tunnels - one client for all WireGuard connections - Secure and remote user enrollment - setting up password, automatically configuring the client for all VPN Locations/Networks - Onboarding - displaying custom onboarding messages, with templates, links ... @@ -117,7 +116,7 @@ The story and motivation behind defguard [can be found here: https://teonite.com ## Features -* [WireGuard®](https://www.wireguard.com/) VPN server with: +* Remote Access: [WireGuard® VPN](https://www.wireguard.com/) server with: - [Multi-Factor Authentication](https://docs.defguard.net/help/desktop-client/multi-factor-authentication-mfa-2fa) with TOTP/Email & Pre-Shared Session Keys - multiple VPN Locations (networks/sites) - with defined access (all users or only Admin group) - multiple [Gateways](https://github.com/DefGuard/gateway) for each VPN Location (**high availability/failover**) - supported on a cluster of routers/firewalls for Linux, FreeBSD/PFSense/OPNSense @@ -129,18 +128,20 @@ The story and motivation behind defguard [can be found here: https://teonite.com - kernel (Linux, FreeBSD/OPNSense/PFSense) & userspace WireGuard® support with [our Rust library](https://github.com/defguard/wireguard-rs) - dashboard and statistics overview of connected users/devices for admins - *defguard is not an official WireGuard® project, and WireGuard is a registered trademark of Jason A. Donenfeld.* -* Integrated SSO: [OpenID Connect provider](https://openid.net/developers/how-connect-works/) - with **unique features**: - - Secure remote (over the internet) [user enrollment](https://docs.defguard.net/help/remote-user-enrollment) - - User [onboarding after enrollment](https://docs.defguard.net/help/remote-user-enrollment/user-onboarding-after-enrollment) - - LDAP (tested on [OpenLDAP](https://www.openldap.org/)) synchronization - - [forward auth](https://docs.defguard.net/features/forward-auth) for reverse proxies (tested with Traefik and Caddy) - - nice UI to manage users - - Users **self-service** (besides typical data management, users can revoke access to granted apps, MFA, WireGuard®, etc.) +* Identity & Account Management: + - SSO based on OpenID Connect](https://openid.net/developers/how-connect-works/) + - Extenal SSO: [external OpenID provider support](https://docs.defguard.net/enterprise/external-openid-providers) - [Multi-Factor/2FA](https://en.wikipedia.org/wiki/Multi-factor_authentication) Authentication: - [Time-based One-Time Password Algorithm](https://en.wikipedia.org/wiki/Time-based_one-time_password) (TOTP - e.g. Google Authenticator) - WebAuthn / FIDO2 - for hardware key authentication support (eg. YubiKey, FaceID, TouchID, ...) - Email based TOTP -* Extenal SSO: [external OpenID provider support](https://docs.defguard.net/enterprise/external-openid-providers) + - LDAP (tested on [OpenLDAP](https://www.openldap.org/)) synchronization + - [forward auth](https://docs.defguard.net/features/forward-auth) for reverse proxies (tested with Traefik and Caddy) + - nice UI to manage users + - Users **self-service** (besides typical data management, users can revoke access to granted apps, MFA, WireGuard®, etc.) +* Account Lifecycle Management: + - Secure remote (over the Internet) [user enrollment](https://docs.defguard.net/help/remote-user-enrollment) - on public web / Desktop Client + - User [onboarding after enrollment](https://docs.defguard.net/help/remote-user-enrollment/user-onboarding-after-enrollment) * SSH & GPG public key management in user profile - with [SSH keys authentication for servers](https://docs.defguard.net/admin-and-features/ssh-authentication) * [Yubikey hardware keys](https://www.yubico.com/) provisioning for users by *one click* * [Email/SMTP support](https://docs.defguard.net/help/setting-up-smtp-for-email-notifications) for notifications, remote enrollment and onboarding diff --git a/migrations/20241108110157_add_on_delete.down.sql b/migrations/20241108110157_add_on_delete.down.sql new file mode 100644 index 000000000..f7680821b --- /dev/null +++ b/migrations/20241108110157_add_on_delete.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE token DROP CONSTRAINT enrollment_admin_id_fkey; +ALTER TABLE token ADD CONSTRAINT enrollment_admin_id_fkey FOREIGN KEY(admin_id) REFERENCES "user"(id); diff --git a/migrations/20241108110157_add_on_delete.up.sql b/migrations/20241108110157_add_on_delete.up.sql new file mode 100644 index 000000000..3ade0c10c --- /dev/null +++ b/migrations/20241108110157_add_on_delete.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE token DROP CONSTRAINT enrollment_admin_id_fkey; +ALTER TABLE token ADD CONSTRAINT enrollment_admin_id_fkey FOREIGN KEY(admin_id) REFERENCES "user"(id) ON DELETE CASCADE; diff --git a/web/package.json b/web/package.json index c7ed2738c..544013ef8 100644 --- a/web/package.json +++ b/web/package.json @@ -71,6 +71,7 @@ "get-text-width": "^1.0.3", "hex-rgb": "^5.0.0", "html-react-parser": "^5.1.1", + "ipaddr.js": "^2.2.0", "itertools": "^2.2.3", "lodash-es": "^4.17.21", "numbro": "^2.4.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ba7fe4b1a..468706420 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: html-react-parser: specifier: ^5.1.1 version: 5.1.1(react@18.2.0) + ipaddr.js: + specifier: ^2.2.0 + version: 2.2.0 itertools: specifier: ^2.2.3 version: 2.2.3 @@ -3268,6 +3271,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@2.2.0: + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + engines: {node: '>= 10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -8814,6 +8821,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@2.2.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index a94620912..ed010dc60 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -856,6 +856,7 @@ const en: BaseTranslation = { portMax: 'Maximum port is 65535.', endpoint: 'Enter a valid endpoint.', address: 'Enter a valid address.', + addressNetmask: 'Enter a valid address with a netmask.', validPort: 'Enter a valid port.', validCode: 'Code should have 6 digits.', allowedIps: 'Only valid IP or domain is allowed.', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 524f84697..c70aa7afe 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -2101,6 +2101,10 @@ type RootTranslation = { * E​n​t​e​r​ ​a​ ​v​a​l​i​d​ ​a​d​d​r​e​s​s​. */ address: string + /** + * E​n​t​e​r​ ​a​ ​v​a​l​i​d​ ​a​d​d​r​e​s​s​ ​w​i​t​h​ ​a​ ​n​e​t​m​a​s​k​. + */ + addressNetmask: string /** * E​n​t​e​r​ ​a​ ​v​a​l​i​d​ ​p​o​r​t​. */ @@ -6356,6 +6360,10 @@ export type TranslationFunctions = { * Enter a valid address. */ address: () => LocalizedString + /** + * Enter a valid address with a netmask. + */ + addressNetmask: () => LocalizedString /** * Enter a valid port. */ diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index 17045129f..b367c0c91 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -841,8 +841,9 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe oneUppercase: 'Wymagana jedna duża litera.', oneLowercase: 'Wymagana jedna mała litera.', portMax: 'Maksymalny numer portu to 65535.', - endpoint: 'Wpisz prawidłowy punkt końcowy.', + endpoint: 'Wpisz poprawny adres.', address: 'Wprowadź poprawny adres.', + addressNetmask: 'Wprowadź poprawny adres IP oraz maskę sieci.', validPort: 'Wprowadź prawidłowy port.', validCode: 'Kod powinien mieć 6 cyfr.', allowedIps: 'Tylko poprawne adresy IP oraz domeny.', diff --git a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx index 32875bcc9..33c358940 100644 --- a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx +++ b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx @@ -2,6 +2,7 @@ import './style.scss'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import ipaddr from 'ipaddr.js'; import { isNull, omit, omitBy } from 'lodash-es'; import { useEffect, useMemo, useRef, useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; @@ -20,11 +21,7 @@ import { QueryKeys } from '../../../shared/queries'; import { Network } from '../../../shared/types'; import { titleCase } from '../../../shared/utils/titleCase'; import { trimObjectStrings } from '../../../shared/utils/trimObjectStrings.ts'; -import { - validateIp, - validateIpOrDomain, - validateIpOrDomainList, -} from '../../../shared/validators'; +import { validateIpOrDomain, validateIpOrDomainList } from '../../../shared/validators'; import { useNetworkPageStore } from '../hooks/useNetworkPageStore'; type FormFields = { @@ -155,17 +152,43 @@ export const NetworkEditForm = () => { if (!netmaskPresent) { return false; } - const ipValid = validateIp(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.address()), + }, LL.form.error.addressNetmask()), endpoint: z .string() .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.required(), @@ -179,7 +202,7 @@ export const NetworkEditForm = () => { if (val === '' || !val) { return true; } - return validateIpOrDomainList(val, ',', true); + return validateIpOrDomainList(val, ',', false, true); }, LL.form.error.allowedIps()), allowed_groups: z.array(z.string().min(1, LL.form.error.minimumLength())), mfa_enabled: z.boolean(), 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 13bf87296..ba22409d1 100644 --- a/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx +++ b/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx @@ -104,7 +104,7 @@ export const SmtpSettingsForm = () => { .string() .min(1, LL.form.error.required()) .refine( - (val) => (!val ? true : validateIpOrDomain(val)), + (val) => (!val ? true : validateIpOrDomain(val, false, true)), LL.form.error.endpoint(), ), smtp_port: z diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx index fbd12098e..cce80f6d5 100644 --- a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx +++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx @@ -20,7 +20,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 { validateIp, validateIpOrDomainList } from '../../../../shared/validators'; +import { validateIpOrDomainList, validateIPv4 } from '../../../../shared/validators'; import { useWizardStore } from '../../hooks/useWizardStore'; type FormInputs = ModifyNetworkRequest['network']; @@ -91,7 +91,7 @@ export const WizardNetworkConfiguration = () => { if (!netmaskPresent) { return false; } - const ipValid = validateIp(value, true); + const ipValid = validateIPv4(value, true); if (ipValid) { const host = value.split('.')[3].split('/')[0]; if (host === '0') return false; diff --git a/web/src/shared/patterns.ts b/web/src/shared/patterns.ts index 1cb06ea8c..6c17b23c9 100644 --- a/web/src/shared/patterns.ts +++ b/web/src/shared/patterns.ts @@ -68,9 +68,6 @@ export const patternValidUrl = new RegExp( 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 patternValidIp = - /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - export const patternSafeUsernameCharacters = /^[a-zA-Z0-9]+[a-zA-Z0-9.\-_]+$/; export const patternSafePasswordCharacters = diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts index 92e538863..c9aa3c3a4 100644 --- a/web/src/shared/validators.ts +++ b/web/src/shared/validators.ts @@ -1,8 +1,18 @@ -import { patternValidDomain, patternValidIp } from './patterns'; +import ipaddr from 'ipaddr.js'; + +import { patternValidDomain } from './patterns'; // Returns flase when invalid -export const validateIpOrDomain = (val: string, allowMask = false): boolean => { - return validateIp(val, allowMask) || patternValidDomain.test(val); +export const validateIpOrDomain = ( + val: string, + allowMask = false, + allowIPv6 = false, +): boolean => { + return ( + (allowIPv6 && validateIPv6(val, allowMask)) || + validateIPv4(val, allowMask) || + patternValidDomain.test(val) + ); }; // Returns flase when invalid @@ -14,7 +24,7 @@ export const validateIpList = ( const trimed = val.replace(' ', ''); const split = trimed.split(splitWith); for (const value of split) { - if (!validateIp(value, allowMasks)) { + if (!validateIPv4(value, allowMasks)) { return false; } } @@ -26,11 +36,17 @@ export const validateIpOrDomainList = ( val: string, splitWith = ',', allowMasks = false, + allowIPv6 = false, ): boolean => { const trimed = val.replace(' ', ''); const split = trimed.split(splitWith); for (const value of split) { - if (!validateIp(value, allowMasks) && !patternValidDomain.test(value)) { + console.log(allowIPv6 && !validateIPv6(value, allowMasks)); + if ( + !validateIPv4(value, allowMasks) && + !patternValidDomain.test(value) && + (!allowIPv6 || !validateIPv6(value, allowMasks)) + ) { return false; } } @@ -38,19 +54,22 @@ export const validateIpOrDomainList = ( }; // Returns flase when invalid -export const validateIp = (ip: string, allowMask = false): boolean => { +export const validateIPv4 = (ip: string, allowMask = false): boolean => { + if (allowMask) { + if (ip.includes('/')) { + ipaddr.IPv4.isValidCIDR(ip); + } + } + return ipaddr.IPv4.isValid(ip); +}; + +export const validateIPv6 = (ip: string, allowMask = false): boolean => { if (allowMask) { if (ip.includes('/')) { - const split = ip.split('/'); - if (split.length !== 2) return true; - const ipValid = patternValidIp.test(split[0]); - if (split[1] === '') return false; - const mask = Number(split[1]); - const maskValid = mask >= 0 && mask <= 32; - return ipValid && maskValid; + ipaddr.IPv6.isValidCIDR(ip); } } - return patternValidIp.test(ip); + return ipaddr.IPv6.isValid(ip); }; export const validatePort = (val: string) => { From 47836384f76fe83a2f28d1d7c23a5b2dc9ac115a Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Wed, 13 Nov 2024 14:33:13 +0100 Subject: [PATCH 2/3] Enable enterprise features when within certain limits (#852) * don't require license if not exceeding the limits * update counts, make basic frontend * code cleanup * cleanup part 2 * sqlx prepare * add limits info * add scopes to tests * polish translation * fix tests * fix frontend tests * fix frontend tests 2 --- ...c938dd921c3288849fdd46fc760ce3dd21882.json | 32 ++++ src/bin/defguard.rs | 7 +- .../db/models/enterprise_settings.rs | 8 +- src/enterprise/grpc/polling.rs | 6 +- src/enterprise/handlers/mod.rs | 27 ++-- src/enterprise/limits.rs | 142 ++++++++++++++++++ src/enterprise/mod.rs | 24 +++ src/grpc/mod.rs | 7 +- src/handlers/app_info.rs | 5 +- src/handlers/user.rs | 3 + src/handlers/wireguard.rs | 10 +- tests/common/mod.rs | 40 +++++ tests/enterprise_settings.rs | 7 + tests/openid_login.rs | 3 + web/src/i18n/en/index.ts | 2 + web/src/i18n/i18n-types.ts | 8 + web/src/i18n/pl/index.ts | 2 + .../EnterpriseSettings/EnterpriseSettings.tsx | 10 ++ .../LicenseSettings/LicenseSettings.tsx | 4 +- .../components/LicenseSettings/styles.scss | 4 + .../OpenIdSettings/OpenIdSettings.tsx | 10 ++ web/src/pages/settings/style.scss | 6 +- .../UsersList/components/UsersListGroups.tsx | 2 +- web/src/shared/defguard-ui | 2 +- web/src/shared/types.ts | 1 + 25 files changed, 339 insertions(+), 33 deletions(-) create mode 100644 .sqlx/query-d5b2165ab0cd9e32296dcfb4e4bc938dd921c3288849fdd46fc760ce3dd21882.json create mode 100644 src/enterprise/limits.rs diff --git a/.sqlx/query-d5b2165ab0cd9e32296dcfb4e4bc938dd921c3288849fdd46fc760ce3dd21882.json b/.sqlx/query-d5b2165ab0cd9e32296dcfb4e4bc938dd921c3288849fdd46fc760ce3dd21882.json new file mode 100644 index 000000000..7212f259f --- /dev/null +++ b/.sqlx/query-d5b2165ab0cd9e32296dcfb4e4bc938dd921c3288849fdd46fc760ce3dd21882.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (SELECT count(*) FROM \"user\") \"user!\", (SELECT count(*) FROM device) \"device!\", (SELECT count(*) FROM wireguard_network) \"wireguard_network!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "device!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "wireguard_network!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "d5b2165ab0cd9e32296dcfb4e4bc938dd921c3288849fdd46fc760ce3dd21882" +} diff --git a/src/bin/defguard.rs b/src/bin/defguard.rs index a25f50a83..ad7565d74 100644 --- a/src/bin/defguard.rs +++ b/src/bin/defguard.rs @@ -7,7 +7,10 @@ use defguard::{ auth::failed_login::FailedLoginMap, config::{Command, DefGuardConfig}, db::{init_db, AppEvent, GatewayEvent, Settings, User}, - enterprise::license::{run_periodic_license_check, set_cached_license, License}, + enterprise::{ + license::{run_periodic_license_check, set_cached_license, License}, + limits::update_counts, + }, grpc::{run_grpc_bidi_stream, run_grpc_server, GatewayMap, WorkerState}, headers::create_user_agent_parser, init_dev_env, init_vpn_location, @@ -101,6 +104,8 @@ async fn main() -> Result<(), anyhow::Error> { let failed_logins = FailedLoginMap::new(); let failed_logins = Arc::new(Mutex::new(failed_logins)); + update_counts(&pool).await?; + debug!("Checking enterprise license status"); match License::load_or_renew(&pool).await { Ok(license) => { diff --git a/src/enterprise/db/models/enterprise_settings.rs b/src/enterprise/db/models/enterprise_settings.rs index 1d14b761c..81a53a20f 100644 --- a/src/enterprise/db/models/enterprise_settings.rs +++ b/src/enterprise/db/models/enterprise_settings.rs @@ -1,7 +1,7 @@ use sqlx::{query, query_as, PgExecutor}; use struct_patch::Patch; -use crate::enterprise::license::{get_cached_license, validate_license}; +use crate::enterprise::is_enterprise_enabled; #[derive(Debug, Deserialize, Patch, Serialize)] #[patch(attribute(derive(Deserialize, Serialize)))] @@ -34,11 +34,7 @@ impl EnterpriseSettings { { // avoid holding the rwlock across await, makes the future !Send // and therefore unusable in axum handlers - let is_valid = { - let license = get_cached_license(); - validate_license(license.as_ref()).is_ok() - }; - if is_valid { + if is_enterprise_enabled() { let settings = query_as!( Self, "SELECT admin_device_management, \ diff --git a/src/enterprise/grpc/polling.rs b/src/enterprise/grpc/polling.rs index 1bf322566..3ea1c1e33 100644 --- a/src/enterprise/grpc/polling.rs +++ b/src/enterprise/grpc/polling.rs @@ -3,7 +3,7 @@ use tonic::Status; use crate::{ db::{models::polling_token::PollingToken, Device, Id, User}, - enterprise::license::{get_cached_license, validate_license}, + enterprise::is_enterprise_enabled, grpc::{ proto::{InstanceInfoRequest, InstanceInfoResponse}, utils::build_device_config_response, @@ -25,8 +25,8 @@ impl PollingServer { debug!("Validating polling token. Token: {token}"); // Polling service is enterprise-only, check the lincense - if validate_license(get_cached_license().as_ref()).is_err() { - debug!("No valid license, denying instance polling info"); + if !is_enterprise_enabled() { + debug!("Instance has enterprise features disabled, denying instance polling info"); return Err(Status::failed_precondition("no valid license")); } diff --git a/src/enterprise/handlers/mod.rs b/src/enterprise/handlers/mod.rs index fb5bba483..651a175bd 100644 --- a/src/enterprise/handlers/mod.rs +++ b/src/enterprise/handlers/mod.rs @@ -1,6 +1,5 @@ use crate::{ auth::SessionInfo, - enterprise::license::validate_license, handlers::{ApiResponse, ApiResult}, }; @@ -14,7 +13,10 @@ use axum::{ http::{request::Parts, StatusCode}, }; -use super::{db::models::enterprise_settings::EnterpriseSettings, license::get_cached_license}; +use super::{ + db::models::enterprise_settings::EnterpriseSettings, is_enterprise_enabled, + license::get_cached_license, needs_enterprise_license, +}; use crate::{appstate::AppState, error::WebError}; pub struct LicenseInfo { @@ -33,19 +35,20 @@ where type Rejection = WebError; async fn from_request_parts(_parts: &mut Parts, _state: &S) -> Result { - let license = get_cached_license(); - - match validate_license(license.as_ref()) { - // Useless struct, but may come in handy later - Ok(()) => Ok(LicenseInfo { valid: true }), - Err(e) => Err(WebError::Forbidden(e.to_string())), + if is_enterprise_enabled() { + Ok(LicenseInfo { valid: true }) + } else { + Err(WebError::Forbidden( + "Enterprise features are disabled".into(), + )) } } } pub async fn check_enterprise_status() -> ApiResult { + let enterprise_enabled = is_enterprise_enabled(); + let needs_license = needs_enterprise_license(); let license = get_cached_license(); - let valid = validate_license((license).as_ref()).is_ok(); let license_info = license.as_ref().map(|license| { serde_json::json!( { @@ -55,8 +58,10 @@ pub async fn check_enterprise_status() -> ApiResult { ) }); Ok(ApiResponse { - json: serde_json::json!({ "enabled": valid, - "license_info": license_info + json: serde_json::json!({ + "enabled": enterprise_enabled, + "needs_license": needs_license, + "license_info": license_info }), status: StatusCode::OK, }) diff --git a/src/enterprise/limits.rs b/src/enterprise/limits.rs new file mode 100644 index 000000000..a9ebfe724 --- /dev/null +++ b/src/enterprise/limits.rs @@ -0,0 +1,142 @@ +use sqlx::{error::Error as SqlxError, query_as, PgPool}; +use std::sync::{RwLock, RwLockReadGuard}; + +#[derive(Debug)] +pub(crate) struct Counts { + user: i64, + device: i64, + wireguard_network: i64, +} + +static COUNTS: RwLock = 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 = { * N​o​ ​l​i​c​e​n​s​e */ noLicense: string + /** + * <​p​>​Y​o​u​ ​h​a​v​e​ ​a​c​c​e​s​s​ ​t​o​ ​t​h​i​s​ ​e​n​t​e​r​p​r​i​s​e​ ​f​e​a​t​u​r​e​,​ ​a​s​ ​y​o​u​ ​h​a​v​e​n​'​t​ ​e​x​c​e​e​d​e​d​ ​a​n​y​ ​o​f​ ​t​h​e​ ​u​s​a​g​e​ ​l​i​m​i​t​s​ ​y​e​t​.​ ​C​h​e​c​k​ ​t​h​e​ ​<​a​ ​h​r​e​f​=​'​h​t​t​p​s​:​/​/​d​o​c​s​.​d​e​f​g​u​a​r​d​.​n​e​t​/​e​n​t​e​r​p​r​i​s​e​/​l​i​c​e​n​s​e​'​>​d​o​c​u​m​e​n​t​a​t​i​o​n​<​/​a​>​ ​f​o​r​ ​m​o​r​e​ ​i​n​f​o​r​m​a​t​i​o​n​.​<​/​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({