From d17291a895270f2561063226349963659fce774f Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 19 Jun 2025 16:15:53 +0200 Subject: [PATCH] Add support for port ranges --- src/components/Callout.tsx | 29 +- src/components/PortSelector.tsx | 469 ++++++++++-------- src/hooks/useLocalStorage.tsx | 17 +- src/interfaces/Policy.ts | 6 + .../access-control/AccessControlModal.tsx | 18 +- .../table/AccessControlPortsCell.tsx | 75 ++- .../access-control/useAccessControl.ts | 45 +- .../routing-peers/NetworkRoutingPeerModal.tsx | 1 + .../RoutingPeerMasqueradeSwitch.tsx | 91 +++- src/modules/routes/RouteModal.tsx | 1 + src/modules/routes/RouteUpdateModal.tsx | 1 + src/utils/helpers.ts | 3 +- tailwind.config.ts | 4 +- 13 files changed, 495 insertions(+), 265 deletions(-) diff --git a/src/components/Callout.tsx b/src/components/Callout.tsx index 97b95ebc..8b453f4d 100644 --- a/src/components/Callout.tsx +++ b/src/components/Callout.tsx @@ -1,26 +1,37 @@ import { cn } from "@utils/helpers"; +import { cva, VariantProps } from "class-variance-authority"; import { InfoIcon } from "lucide-react"; import * as React from "react"; +type CalloutVariants = VariantProps; + type Props = { icon?: React.ReactNode; children?: React.ReactNode; className?: string; -}; +} & CalloutVariants; + +export const calloutVariants = cva( + ["px-4 py-3.5 rounded-md border text-sm font-normal flex gap-3 font-light"], + { + variants: { + variant: { + default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300", + warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150", + info: "bg-sky-400/10 border-sky-400/20 text-sky-100", + }, + }, + }, +); export const Callout = ({ children, - icon = , + icon = , className, + variant = "default", }: Props) => { return ( -
+
{icon}
{children}
diff --git a/src/components/PortSelector.tsx b/src/components/PortSelector.tsx index 0a0e306c..0fa2b3cf 100644 --- a/src/components/PortSelector.tsx +++ b/src/components/PortSelector.tsx @@ -1,261 +1,328 @@ import Badge from "@components/Badge"; +import { Callout } from "@components/Callout"; import { Checkbox } from "@components/Checkbox"; import { CommandItem } from "@components/Command"; +import { DropdownInfoText } from "@components/DropdownInfoText"; import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover"; import { IconArrowBack } from "@tabler/icons-react"; import { cn } from "@utils/helpers"; import { Command, CommandGroup, CommandInput, CommandList } from "cmdk"; -import { trim } from "lodash"; +import { orderBy, trim } from "lodash"; import { ChevronsUpDown, SearchIcon, XIcon } from "lucide-react"; import * as React from "react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useElementSize } from "@/hooks/useElementSize"; +import { PortRange } from "@/interfaces/Policy"; interface MultiSelectProps { - values: number[]; - onChange: React.Dispatch>; + ports: number[]; + onPortsChange: React.Dispatch>; + portRanges?: PortRange[]; + onPortRangesChange?: React.Dispatch>; max?: number; disabled?: boolean; popoverWidth?: "auto" | number; showAll?: boolean; } + +const isValidPort = (p: number) => p >= 1 && p <= 65535; + +const parseRange = (value: string): PortRange | undefined => { + const parts = value.split("-").map((x) => Number(trim(x))); + if (parts.length !== 2) return undefined; + const [start, end] = parts; + if (!isValidPort(start) || !isValidPort(end) || start >= end) + return undefined; + return { start, end }; +}; + +const parsePortInput = (value: string): number | PortRange | undefined => { + const trimmed = trim(value); + if (/^\d{1,5}-\d{1,5}$/.test(trimmed)) return parseRange(trimmed); + const port = Number(trimmed); + return isValidPort(port) ? port : undefined; +}; + export function PortSelector({ - onChange, - values, - max, + onPortsChange, + ports, + portRanges = [], + onPortRangesChange, disabled = false, popoverWidth = "auto", showAll = false, -}: MultiSelectProps) { +}: Readonly) { const searchRef = React.useRef(null); + const [open, setOpen] = useState(false); const [inputRef, { width }] = useElementSize(); const [search, setSearch] = useState(""); - const toggle = (x: number) => { - if (isNaN(Number(x))) return; - const port = Number(x); - if (port < 1 || port > 65535) return; + const [portsInput, setPortsInput] = useState(() => { + const p = ports.map(String); + const pr = portRanges.map((r) => { + if (r.start === r.end) return String(r.start); + return `${r.start}-${r.end}`; + }); + return orderBy([...p, ...pr], [(x) => Number(x.split("-")[0])], ["asc"]); + }); + + useEffect(() => { + const parsed = portsInput.map(parsePortInput).filter(Boolean); + const newPorts: number[] = []; + const newRanges: PortRange[] = []; + parsed.forEach((entry) => { + if (typeof entry === "number") newPorts.push(entry); + else if (entry !== undefined) newRanges.push(entry); + }); + onPortsChange(newPorts); + onPortRangesChange?.(newRanges); + }, [portsInput]); - const isSelected = values.includes(port); - if (isSelected) { - onChange((previous) => previous.filter((y) => y !== port)); - } else { - onChange((previous) => [...previous, port]); - setSearch(""); - } + const toggle = (value: string) => { + if (disabled) return; + setPortsInput((prev) => + prev.includes(value) ? prev.filter((e) => e !== value) : [...prev, value], + ); + setSearch(""); }; const notFound = useMemo(() => { const isSearching = search.length > 0; - const found = - values.filter((item) => item == Number(trim(search))).length == 0; - return isSearching && found; - }, [search, values]); - - const [open, setOpen] = useState(false); + const trimmed = trim(search); + return ( + trimmed && + !portsInput.includes(trimmed) && + parsePortInput(trimmed) && + isSearching + ); + }, [search, portsInput]); return ( - { - if (!isOpen) { - setTimeout(() => { - setSearch(""); - }, 100); - } - setOpen(isOpen); - }} - > - -
- - - - - { - const formatValue = trim(value.toLowerCase()); - const formatSearch = trim(search.toLowerCase()); - if (formatValue.includes(formatSearch)) return 1; - return 0; + + + + - -
- -
-
- + { + const formatValue = trim(value.toLowerCase()); + const formatSearch = trim(search.toLowerCase()); + if (formatValue.includes(formatSearch)) return 1; + return 0; + }} + > + +
+ +
+
+ +
-
-
- +
+ +
-
-
- {notFound && ( - -
- { - toggle(Number(search)); - searchRef.current?.focus(); - }} - value={search} - onClick={(e) => e.preventDefault()} - > - - {search} - -
- Add this port by pressing{" "} - - {"'Enter'"} - -
-
+
+ {!notFound && search && !portsInput.includes(search) && ( +
+ + { + "Please add a valid port or port range (e.g. 80, 443, 1-1023)" + } +
- - )} + )} - -
- {values.map((option) => { - const isSelected = values.includes(option); - return ( + {notFound && ( + +
{ - toggle(option); + toggle(search); searchRef.current?.focus(); }} + value={search} onClick={(e) => e.preventDefault()} > -
- - {option} - -
- -
- + {search} + +
+ Add this port or range by pressing{" "} + + {"'Enter'"} +
- ); - })} -
- -
- - - - +
+
+ )} + + +
+ {portsInput.map((option) => { + const isSelected = portsInput.includes(option); + return ( + { + toggle(option); + searchRef.current?.focus(); + }} + onClick={(e) => e.preventDefault()} + > +
+ + {option} + +
+ +
+ +
+
+ ); + })} +
+
+
+ + + + + {portRanges?.length > 0 && ( + + Port ranges requires NetBird client{" "} + v0.48 or higher. + + )} + ); } diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx index f6e91d2d..3f2e9f6d 100644 --- a/src/hooks/useLocalStorage.tsx +++ b/src/hooks/useLocalStorage.tsx @@ -3,6 +3,7 @@ import { type SetStateAction, useCallback, useEffect, + useRef, useState, } from "react"; import { useEventCallback } from "@/hooks/useEventCallback"; @@ -20,8 +21,10 @@ export function useLocalStorage( key: string, initialValue: T, enabled: boolean = true, + overrideValue?: T, ): [T, SetValue] { - const [tempValue, setTempValue] = useState(initialValue); + const [tempValue, setTempValue] = useState(overrideValue ?? initialValue); + const isInitialRender = useRef(true); // Get from local storage then // parse stored json or return initialValue @@ -31,6 +34,11 @@ export function useLocalStorage( return initialValue; } + if (isInitialRender.current && overrideValue !== undefined) { + isInitialRender.current = false; + return overrideValue; + } + try { const item = window.localStorage.getItem(key); return item ? (parseJSON(item) as T) : initialValue; @@ -95,6 +103,13 @@ export function useLocalStorage( [key, readValue], ); + useEffect(() => { + if (overrideValue) { + setValue(overrideValue); + setStoredValue(overrideValue); + } + }, []); + // this only works for other documents, not the current one useEventListener("storage", handleStorageChange); diff --git a/src/interfaces/Policy.ts b/src/interfaces/Policy.ts index c0136207..2ad24aee 100644 --- a/src/interfaces/Policy.ts +++ b/src/interfaces/Policy.ts @@ -22,10 +22,16 @@ export interface PolicyRule { action: string; protocol: Protocol; ports: string[]; + port_ranges?: PortRange[]; sourceResource?: PolicyRuleResource; destinationResource?: PolicyRuleResource; } +export interface PortRange { + start: number; + end: number; +} + export interface PolicyRuleResource { id: string; type: "domain" | "host" | "subnet" | undefined; diff --git a/src/modules/access-control/AccessControlModal.tsx b/src/modules/access-control/AccessControlModal.tsx index 5bebc257..62fe001e 100644 --- a/src/modules/access-control/AccessControlModal.tsx +++ b/src/modules/access-control/AccessControlModal.tsx @@ -154,6 +154,8 @@ export function AccessControlModalContent({ getPolicyData, destinationResource, setDestinationResource, + portRanges, + setPortRanges, } = useAccessControl({ policy, postureCheckTemplates, @@ -166,15 +168,13 @@ export function AccessControlModalContent({ const [tab, setTab] = useState(() => { if (!cell) return "policy"; if (cell == "posture_checks") return "posture_checks"; - if (cell == "name") return "general"; return "policy"; }); const continuePostureChecksDisabled = useMemo(() => { - if (direction != "bi" && ports.length == 0) return true; if (sourceGroups.length > 0 && destinationResource) return false; if (sourceGroups.length == 0 || destinationGroups.length == 0) return true; - }, [sourceGroups, destinationGroups, direction, ports, destinationResource]); + }, [sourceGroups, destinationGroups, destinationResource]); const submitDisabled = useMemo(() => { if (name.length == 0) return true; @@ -185,9 +185,11 @@ export function AccessControlModalContent({ setProtocol(p); if (p == "icmp") { setPorts([]); + setPortRanges([]); } if (p == "all") { setPorts([]); + setPortRanges([]); } if (p == "tcp" || p == "udp") { setDirection("in"); @@ -340,14 +342,16 @@ export function AccessControlModalContent({ Allow network traffic and access only to specified ports. - Select ports between 1 and 65535. + Select ports or port ranges between 1 and 65535.
diff --git a/src/modules/access-control/table/AccessControlPortsCell.tsx b/src/modules/access-control/table/AccessControlPortsCell.tsx index 20e464ec..acfe0d19 100644 --- a/src/modules/access-control/table/AccessControlPortsCell.tsx +++ b/src/modules/access-control/table/AccessControlPortsCell.tsx @@ -5,29 +5,45 @@ import { TooltipProvider, TooltipTrigger, } from "@components/Tooltip"; +import { orderBy } from "lodash"; import React, { useMemo } from "react"; import { Policy } from "@/interfaces/Policy"; type Props = { policy: Policy; }; -export default function AccessControlPortsCell({ policy }: Props) { - const firstRule = useMemo(() => { + +export default function AccessControlPortsCell({ policy }: Readonly) { + const rule = useMemo(() => { if (policy.rules.length > 0) return policy.rules[0]; return undefined; }, [policy]); - const hasPorts = firstRule?.ports && firstRule?.ports.length > 0; + const hasPorts = rule?.ports && rule?.ports?.length > 0; + const hasPortRanges = rule?.port_ranges && rule?.port_ranges?.length > 0; + const hasAnyPorts = hasPorts || hasPortRanges; + + const allPorts = useMemo(() => { + const ports = rule?.ports ?? []; + const portRanges = + rule?.port_ranges?.map((r) => { + if (r.start === r.end) return `${r.start}`; + return `${r.start}-${r.end}`; + }) ?? []; + return orderBy( + [...portRanges, ...ports], + [(p) => Number(p.split("-")[0])], + ["asc"], + ); + }, [rule]); const firstTwoPorts = useMemo(() => { - if (!hasPorts) return []; - return firstRule?.ports.slice(0, 2) ?? []; - }, [hasPorts, firstRule]); + return allPorts?.slice(0, 2) ?? []; + }, [allPorts]); const otherPorts = useMemo(() => { - if (!hasPorts) return []; - return firstRule?.ports.slice(2) ?? []; - }, [hasPorts, firstRule]); + return allPorts?.slice(2) ?? []; + }, [allPorts]); return (
@@ -35,7 +51,7 @@ export default function AccessControlPortsCell({ policy }: Props) {
- {!hasPorts && ( + {!hasAnyPorts && ( )} - {firstTwoPorts && - firstTwoPorts.map((port) => { - return ( - - {port} - - ); - })} + {firstTwoPorts?.map((port) => { + return ( + + {port} + + ); + })} {otherPorts && otherPorts.length > 0 && ( {otherPorts && otherPorts.length > 0 && ( -
+
{otherPorts.map((port) => { - return {port}; + return ( + + {port} + + ); })}
diff --git a/src/modules/access-control/useAccessControl.ts b/src/modules/access-control/useAccessControl.ts index 06a403c8..b2325c2d 100644 --- a/src/modules/access-control/useAccessControl.ts +++ b/src/modules/access-control/useAccessControl.ts @@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useSWRConfig } from "swr"; import { usePolicies } from "@/contexts/PoliciesProvider"; import { Group } from "@/interfaces/Group"; -import { Policy, Protocol } from "@/interfaces/Policy"; +import { Policy, PortRange, Protocol } from "@/interfaces/Policy"; import { PostureCheck } from "@/interfaces/PostureCheck"; import useGroupHelper from "@/modules/groups/useGroupHelper"; import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck"; @@ -83,6 +83,15 @@ export const useAccessControl = ({ return []; }); + const [portRanges, setPortRanges] = useState(() => { + if (!firstRule) return []; + if (firstRule.port_ranges == undefined) return []; + if (firstRule.port_ranges.length > 0) { + return firstRule.port_ranges; + } + return []; + }); + const [protocol, setProtocol] = useState( firstRule ? firstRule.protocol : "all", ); @@ -139,6 +148,11 @@ export const useAccessControl = ({ destinations = tmp; } + const [newPorts, newPortRanges] = parseAccessControlPorts( + ports, + portRanges, + ); + return { name, description, @@ -155,7 +169,8 @@ export const useAccessControl = ({ action: "accept", protocol, enabled, - ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined, + ports: newPorts, + port_ranges: newPortRanges, }, ], } as Policy; @@ -206,6 +221,11 @@ export const useAccessControl = ({ destinations = tmp; } + const [newPorts, newPortRanges] = parseAccessControlPorts( + ports, + portRanges, + ); + const policyObj = { name, description, @@ -224,7 +244,8 @@ export const useAccessControl = ({ sources, destinations: destinationResource ? undefined : destinations, destinationResource: destinationResource || undefined, - ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined, + ports: newPorts, + port_ranges: newPortRanges, }, ], } as Policy; @@ -267,6 +288,8 @@ export const useAccessControl = ({ setEnabled, ports, setPorts, + portRanges, + setPortRanges, sourceGroups, setSourceGroups, destinationGroups, @@ -281,3 +304,19 @@ export const useAccessControl = ({ setDestinationResource, } as const; }; + +const parseAccessControlPorts = (ports: number[], portRanges: PortRange[]) => { + const hasRanges = portRanges.length > 0; + const hasPorts = ports.length > 0; + if (!hasPorts && !hasRanges) return [undefined, undefined]; + if (!hasRanges) return [ports.map(String), undefined]; + if (!hasPorts) return [undefined, portRanges]; + + const portRangesFromPorts = ports.map((port) => ({ + start: port, + end: port, + })) as PortRange[]; + + const allRanges = [...portRanges, ...portRangesFromPorts]; + return [undefined, allRanges]; +}; diff --git a/src/modules/networks/routing-peers/NetworkRoutingPeerModal.tsx b/src/modules/networks/routing-peers/NetworkRoutingPeerModal.tsx index 4ec6ef29..5fb7966a 100644 --- a/src/modules/networks/routing-peers/NetworkRoutingPeerModal.tsx +++ b/src/modules/networks/routing-peers/NetworkRoutingPeerModal.tsx @@ -314,6 +314,7 @@ function RoutingPeerModalContent({ value={masquerade} onChange={setMasquerade} disabled={isNonLinuxRoutingPeer} + routingPeerGroupId={routingPeerGroups?.[0]?.id} />
diff --git a/src/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch.tsx b/src/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch.tsx index 4eb56d59..632927a9 100644 --- a/src/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch.tsx +++ b/src/modules/networks/routing-peers/RoutingPeerMasqueradeSwitch.tsx @@ -1,34 +1,51 @@ +import { Callout } from "@components/Callout"; import FancyToggleSwitch from "@components/FancyToggleSwitch"; import FullTooltip from "@components/FullTooltip"; -import { VenetianMask } from "lucide-react"; +import { getOperatingSystem } from "@hooks/useOperatingSystem"; +import useFetchApi from "@utils/api"; +import { cn } from "@utils/helpers"; +import { AlertCircleIcon, VenetianMask } from "lucide-react"; import * as React from "react"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { GroupPeer } from "@/interfaces/Group"; +import { OperatingSystem } from "@/interfaces/OperatingSystem"; +import { Peer } from "@/interfaces/Peer"; type Props = { value: boolean; onChange: (value: boolean) => void; disabled?: boolean; + routingPeerGroupId?: string; }; export const RoutingPeerMasqueradeSwitch = ({ disabled = false, value, onChange, + routingPeerGroupId, }: Props) => { return ( - - - Masquerade - - } - helpText={ - "Allow access to your private networks without configuring routes on your local routers or other devices." - } - /> +
+ + + Masquerade + + } + helpText={ + "Allow access to your private networks without configuring routes on your local routers or other devices." + } + /> + {routingPeerGroupId && !value && ( + + )} +
); }; @@ -52,9 +69,51 @@ export const RoutingPeerMasqueradeTooltip = ({ delayDuration={250} skipDelayDuration={350} disabled={!show} - className={"cursor-help"} + className={cn(show && "cursor-help")} > {children} ); }; + +const RoutingPeerGroupNonLinuxWarning = ({ + routingPeerGroupId, +}: { + routingPeerGroupId: string; +}) => { + const { groups } = useGroups(); + const { data: peers } = useFetchApi("/peers", true); + const group = groups?.find((g) => g.id === routingPeerGroupId); + + const hasNonLinuxPeer = React.useMemo(() => { + try { + return group?.peers?.some((groupPeer) => { + const peer = peers?.find((p) => p.id === (groupPeer as GroupPeer).id); + if (!peer) return false; + const os = getOperatingSystem(peer.os); + return os !== OperatingSystem.LINUX; + }); + } catch (e) { + return false; + } + }, [group?.peers, peers]); + + return ( + hasNonLinuxPeer && ( + + } + > + Group {group?.name}{" "} + contains at least one non-Linux peer. +
Disabled Masquerade will have no effect on non-Linux routing + peers. +
+ ) + ); +}; diff --git a/src/modules/routes/RouteModal.tsx b/src/modules/routes/RouteModal.tsx index 2985f0ee..2763fa99 100644 --- a/src/modules/routes/RouteModal.tsx +++ b/src/modules/routes/RouteModal.tsx @@ -723,6 +723,7 @@ export function RouteModalContent({ value={masquerade} onChange={setMasquerade} disabled={isNonLinuxRoutingPeer} + routingPeerGroupId={routingPeerGroups?.[0]?.id} /> )} diff --git a/src/modules/routes/RouteUpdateModal.tsx b/src/modules/routes/RouteUpdateModal.tsx index 8c50a5ae..5b18d425 100644 --- a/src/modules/routes/RouteUpdateModal.tsx +++ b/src/modules/routes/RouteUpdateModal.tsx @@ -461,6 +461,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) { value={masquerade} onChange={setMasquerade} disabled={isNonLinuxRoutingPeer} + routingPeerGroupId={routingPeerGroups?.[0]?.id} /> )}
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index d4753fa9..0a0ac6aa 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -24,7 +24,8 @@ export function removeAllSpaces(str: string) { return str.replace(/\s/g, ""); } -export const generateColorFromString = (str: string) => { +export const generateColorFromString = (str?: string) => { + if (!str) return "#f68330"; if (str.includes("System")) return "#808080"; if (str.toLowerCase().startsWith("netbird")) return "#f68330"; let hash = 0; diff --git a/tailwind.config.ts b/tailwind.config.ts index 902eff1c..9a5a6782 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -14,6 +14,7 @@ const config: Config = { "50": "#f4f6f7", "100": "#e4e7e9", "200": "#cbd2d6", + "250": "#b7c0c6", "300": "#a7b1b9", "350": "#8f9ca8", "400": "#7c8994", @@ -34,7 +35,8 @@ const config: Config = { DEFAULT: "#f68330", "50": "#fff6ed", "100": "#feecd6", - "200": "#fcd5ac", + "150": "#ffdfb8", + "200": "#ffd4a6", "300": "#fab677", "400": "#f68330", "500": "#f46d1b",