diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 330ef2b0..a484d17e 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -14,5 +14,6 @@ export interface Account { jwt_allow_groups: string[]; regular_users_view_blocked: boolean; routing_peer_dns_resolution_enabled: boolean; + dns_domain: string; }; } diff --git a/src/modules/networks/resources/ResourceSingleAddressInput.tsx b/src/modules/networks/resources/ResourceSingleAddressInput.tsx index 89942c04..a0a78a77 100644 --- a/src/modules/networks/resources/ResourceSingleAddressInput.tsx +++ b/src/modules/networks/resources/ResourceSingleAddressInput.tsx @@ -31,7 +31,7 @@ export const ResourceSingleAddressInput = ({ value, onChange }: Props) => { // Case 1: If it has characters (potential domain) but is not a CIDR block if (hasChars && !isCIDRBlock) { - if (!validator.isValidDomainWithWildcard(value)) { + if (!validator.isValidDomain(value)) { return "Please enter a valid domain, e.g. intra.example.com or *.example.com"; } return ""; // Valid domain diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index fe45cdf7..25d4c1d6 100644 --- a/src/modules/settings/NetworkSettingsTab.tsx +++ b/src/modules/settings/NetworkSettingsTab.tsx @@ -1,10 +1,18 @@ import Breadcrumbs from "@components/Breadcrumbs"; +import Button from "@components/Button"; import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import HelpText from "@components/HelpText"; +import InlineLink from "@components/InlineLink"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; import { notify } from "@components/Notification"; +import { useHasChanges } from "@hooks/useHasChanges"; import * as Tabs from "@radix-ui/react-tabs"; import { useApiCall } from "@utils/api"; -import { GlobeIcon, NetworkIcon } from "lucide-react"; -import React, { useState } from "react"; +import { validator } from "@utils/helpers"; +import { isNetBirdHosted } from "@utils/netbird"; +import { ExternalLinkIcon, GlobeIcon, NetworkIcon } from "lucide-react"; +import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; import SettingsIcon from "@/assets/icons/SettingsIcon"; import { Account } from "@/interfaces/Account"; @@ -13,18 +21,23 @@ type Props = { account: Account; }; -export default function NetworkSettingsTab({ account }: Props) { +export default function NetworkSettingsTab({ account }: Readonly) { const { mutate } = useSWRConfig(); - const saveRequest = useApiCall("/accounts/" + account.id); + const saveRequest = useApiCall("/accounts/" + account.id, true); const [routingPeerDNSSetting, setRoutingPeerDNSSetting] = useState( account.settings.routing_peer_dns_resolution_enabled, ); + const [customDNSDomain, setCustomDNSDomain] = useState( + account.settings.dns_domain || "", + ); - const toggleSetting = async (toggle: boolean) => { + const toggleNetworkDNSSetting = async (toggle: boolean) => { notify({ - title: "Save Network Settings", - description: "Network settings successfully saved.", + title: "DNS Wildcard Routing", + description: `DNS Wildcard Routing successfully ${ + toggle ? "enabled" : "disabled" + }.`, promise: saveRequest .put({ id: account.id, @@ -37,10 +50,43 @@ export default function NetworkSettingsTab({ account }: Props) { setRoutingPeerDNSSetting(toggle); mutate("/accounts"); }), - loadingMessage: "Saving the network settings...", + loadingMessage: "Updating DNS wildcard setting...", }); }; + const { hasChanges, updateRef } = useHasChanges([customDNSDomain]); + + const saveChanges = async () => { + notify({ + title: "Custom DNS Domain", + description: `Custom DNS Domain successfully updated.`, + promise: saveRequest + .put({ + id: account.id, + settings: { + ...account.settings, + dns_domain: customDNSDomain || "", + }, + }) + .then(() => { + mutate("/accounts"); + updateRef([customDNSDomain]); + }), + loadingMessage: "Updating Custom DNS domain...", + }); + }; + + const domainError = useMemo(() => { + if (customDNSDomain == "") return ""; + const valid = validator.isValidDomain(customDNSDomain, { + allowWildcard: false, + allowOnlyTld: false, + }); + if (!valid) { + return "Please enter a valid domain, e.g. example.com or intra.example.com"; + } + }, [customDNSDomain]); + return (
@@ -51,36 +97,80 @@ export default function NetworkSettingsTab({ account }: Props) { icon={} /> } active />
-

Networks

+
+

Networks

+
+
- - - Enable DNS Wildcard Routing - - } - helpText={ - <> - Allow routing using DNS wildcards. This requires NetBird - client v0.35 or higher. Changes will only take effect after - restarting the clients. - +
+ > +
+ + + Specify a custom DNS domain for your network. This will be + used for all your peers. + +
+
+ setCustomDNSDomain(e.target.value)} + /> +
+
+ + + + Enable DNS Wildcard Routing + + } + helpText={ + <> + Allow routing using DNS wildcards. This requires NetBird client + v0.35 or higher. Changes will only take effect after restarting + the clients.{" "} + + Learn more + + + + } + />
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 9a548b5f..e5d48d75 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -41,47 +41,37 @@ export const sleep = (ms: number) => { }; export const validator = { - isValidDomain: (domain: string) => { - const unicodeDomain = - /^(?!.*\.\.)(?!.*\.$)(?!.*\s)(?:(?!-)(?!.*--)[a-zA-Z0-9\u00A1-\uFFFF-]{1,63}(?= minMaxChars[0] && domain.length <= minMaxChars[1]; - const includesDot = domain.includes("."); - const hasNoWhitespace = !domain.includes(" "); - return ( - unicodeDomain.test(domain) && - includesDot && - hasNoWhitespace && - isValidDomainLength - ); - } catch (e) { - return false; - } - }, - isValidDomainWithWildcard: (domain: string) => { - // Basic checks - if (!domain || domain.length > 255 || domain.includes(" ")) { - return false; - } + isValidDomain: ( + domain: string, + options?: { allowWildcard?: boolean; allowOnlyTld?: boolean }, + ) => { + const { allowWildcard = true, allowOnlyTld = true } = options || { + allowWildcard: true, + allowOnlyTld: true, + }; - // Handle wildcard - if (domain.includes("*")) { - if (!domain.startsWith("*.") || domain.indexOf("*", 1) !== -1) { + try { + const includesAtLeastOneDot = domain.includes("."); + const hasWhitespace = domain.includes(" "); + const domainRegex = + /^(?!-)[a-z0-9\u00a1-\uffff-*]{0,63}(? 255) { return false; } - domain = "sub" + domain.slice(1); // Replace * with valid subdomain for testing - } - - // Split and validate each part - const parts = domain.split("."); - if (parts.length < 2) { + if (!allowWildcard && domain.startsWith("*.")) { + return false; + } + if (allowWildcard && !domain.startsWith("*.") && domain.includes("*")) { + return false; + } + if (!allowOnlyTld && domain.startsWith(".")) { + return false; + } + return includesAtLeastOneDot && isValidUnicodeDomain && !hasWhitespace; + } catch (error) { return false; } - - const validPart = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; - return parts.every((part) => validPart.test(part)); }, isValidEmail: (email: string) => { const regExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;