Compare commits

...

1 Commits

Author SHA1 Message Date
Eduard Gert
8e2cbe1d2a Add support for port ranges (#475)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-06-20 10:26:53 +02:00
13 changed files with 506 additions and 276 deletions

View File

@@ -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<typeof calloutVariants>;
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 = <InfoIcon size={14} className={"shrink-0 relative top-[1px]"} />,
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
className,
variant = "default",
}: Props) => {
return (
<div
className={cn(
"px-4 py-3 rounded-md border text-sm font-normal flex gap-3",
"bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
className,
)}
>
<div className={cn(calloutVariants({ variant }), className)}>
{icon}
<div>{children}</div>
</div>

View File

@@ -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<React.SetStateAction<number[]>>;
ports: number[];
onPortsChange: React.Dispatch<React.SetStateAction<number[]>>;
portRanges?: PortRange[];
onPortRangesChange?: React.Dispatch<React.SetStateAction<PortRange[]>>;
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<MultiSelectProps>) {
const searchRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
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<string[]>(() => {
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"]);
});
const isSelected = values.includes(port);
if (isSelected) {
onChange((previous) => previous.filter((y) => y !== port));
} else {
onChange((previous) => [...previous, port]);
setSearch("");
}
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 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 (
<Popover
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
setTimeout(() => {
setSearch("");
}, 100);
}
setOpen(isOpen);
}}
>
<PopoverTrigger asChild>
<button
className={cn(
"min-h-[48px] w-full relative items-center",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
)}
data-cy={"port-selector"}
disabled={disabled}
ref={inputRef}
>
<div
className={
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
}
>
{values.length === 0 && showAll && (
<Badge
variant={"gray"}
className={"uppercase tracking-wider font-medium py-1"}
>
All
</Badge>
)}
{values.map((x) => (
<Badge
key={x}
variant={"gray"}
onClick={() => toggle(x)}
className={"uppercase tracking-wider font-medium py-1"}
>
{x}
<XIcon
size={12}
className={"cursor-pointer group-hover:text-black"}
/>
</Badge>
))}
{values.length == 0 && <span>Select ports...</span>}
</div>
<ChevronsUpDown size={18} className={"shrink-0"} />
</button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0 shadow-sm shadow-nb-gray-950"
style={{
width: popoverWidth === "auto" ? width : popoverWidth,
<>
<Popover
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
setTimeout(() => {
setSearch("");
}, 100);
}
setOpen(isOpen);
}}
align="start"
side={"top"}
sideOffset={10}
>
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
if (formatValue.includes(formatSearch)) return 1;
return 0;
}}
>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
)}
data-cy={"port-input"}
typeof={"number"}
ref={searchRef}
value={search}
onValueChange={setSearch}
placeholder={'Add new ports by pressing "Enter"...'}
/>
<div
className={
"absolute left-0 top-0 h-full flex items-center pl-4"
}
>
<div className={"flex items-center"}>
<SearchIcon size={14} />
</div>
</div>
<div
className={
"absolute right-0 top-0 h-full flex items-center pr-4"
}
>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
<PopoverTrigger asChild>
<button
className={cn(
"min-h-[48px] w-full relative items-center",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
)}
data-cy={"port-selector"}
disabled={disabled}
ref={inputRef}
>
<div
className={
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
}
>
{portsInput.length === 0 && showAll && (
<Badge
variant={"gray"}
className={"uppercase tracking-wider font-medium py-1"}
>
<IconArrowBack size={10} />
</div>
</div>
All
</Badge>
)}
{portsInput.map((x) => (
<Badge
key={x}
variant={"gray"}
onClick={() => toggle(x)}
className={"uppercase tracking-wider font-medium py-1"}
>
{x}
<XIcon
size={12}
className={"cursor-pointer group-hover:text-black"}
/>
</Badge>
))}
{ports.length == 0 && <span>Select ports...</span>}
</div>
<div
className={cn(
"flex flex-col gap-2",
values.length != 0 && "p-2",
values.length != 0 && search && "p-2",
values.length == 0 && search && "p-2",
)}
>
{notFound && (
<ChevronsUpDown size={18} className={"shrink-0"} />
</button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0 shadow-sm shadow-nb-gray-950"
style={{
width: popoverWidth === "auto" ? width : popoverWidth,
}}
align="start"
side={"top"}
sideOffset={10}
>
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
if (formatValue.includes(formatSearch)) return 1;
return 0;
}}
>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
className={cn(
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
data-cy={"port-input"}
typeof={"number"}
ref={searchRef}
value={search}
onValueChange={setSearch}
placeholder={
'Add a port or a range e.g. 80 or 1-1023 and press "Enter" to add...'
}
/>
<div
className={
"absolute left-0 top-0 h-full flex items-center pl-4"
}
>
<div className={"flex items-center"}>
<SearchIcon size={14} />
</div>
</div>
<div
className={
"absolute right-0 top-0 h-full flex items-center pr-4"
}
>
<div
className={
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
}
>
<IconArrowBack size={10} />
</div>
</div>
</div>
<div
className={cn(
"flex flex-col gap-2",
portsInput.length != 0 && "p-2",
portsInput.length != 0 && search && "p-2",
notFound && "p-2",
)}
>
{!notFound && search && !portsInput.includes(search) && (
<div className={"text-sm"}>
<DropdownInfoText className={"mb-[18px] pt-[4px]"}>
{
"Please add a valid port or port range (e.g. 80, 443, 1-1023)"
}
</DropdownInfoText>
</div>
)}
{notFound && (
<CommandGroup>
<div
className={cn(
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
)}
>
<CommandItem
key={search}
onSelect={() => {
toggle(search);
searchRef.current?.focus();
}}
value={search}
onClick={(e) => e.preventDefault()}
>
<Badge
variant={"gray"}
className={
"uppercase tracking-wider font-medium py-1"
}
>
{search}
</Badge>
<div
className={"text-neutral-500 dark:text-nb-gray-300"}
>
Add this port or range by pressing{" "}
<span className={"font-bold text-netbird"}>
{"'Enter'"}
</span>
</div>
</CommandItem>
</div>
</CommandGroup>
)}
<CommandGroup>
<div
className={cn(
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
)}
>
<CommandItem
key={search}
onSelect={() => {
toggle(Number(search));
searchRef.current?.focus();
}}
value={search}
onClick={(e) => e.preventDefault()}
>
<Badge
variant={"gray"}
className={"uppercase tracking-wider font-medium py-1"}
>
{search}
</Badge>
<div className={"text-neutral-500 dark:text-nb-gray-300"}>
Add this port by pressing{" "}
<span className={"font-bold text-netbird"}>
{"'Enter'"}
</span>
</div>
</CommandItem>
</div>
</CommandGroup>
)}
{portsInput.map((option) => {
const isSelected = portsInput.includes(option);
return (
<CommandItem
key={option}
value={option.toString()}
onSelect={() => {
toggle(option);
searchRef.current?.focus();
}}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<Badge
variant={"gray"}
className={
"uppercase tracking-wider font-medium py-1"
}
>
{option}
</Badge>
</div>
<CommandGroup>
<div
className={cn(
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
)}
>
{values.map((option) => {
const isSelected = values.includes(option);
return (
<CommandItem
key={option}
value={option.toString()}
onSelect={() => {
toggle(option);
searchRef.current?.focus();
}}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<Badge
variant={"gray"}
<div
className={
"uppercase tracking-wider font-medium py-1"
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{option}
</Badge>
</div>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
<Checkbox checked={isSelected} />
</div>
</CommandItem>
);
})}
</div>
</CommandGroup>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Checkbox checked={isSelected} />
</div>
</CommandItem>
);
})}
</div>
</CommandGroup>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{portRanges?.length > 0 && (
<Callout variant={"info"} className={"mt-4"}>
Port ranges requires NetBird client{" "}
<span className={"text-white font-normal"}>v0.48</span> or higher.
</Callout>
)}
</>
);
}

View File

@@ -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<T>(
key: string,
initialValue: T,
enabled: boolean = true,
overrideValue?: T,
): [T, SetValue<T>] {
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<T>(
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<T>(
[key, readValue],
);
useEffect(() => {
if (overrideValue) {
setValue(overrideValue);
setStoredValue(overrideValue);
}
}, []);
// this only works for other documents, not the current one
useEventListener("storage", handleStorageChange);

View File

@@ -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;

View File

@@ -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({
</Label>
<HelpText>
Allow network traffic and access only to specified ports.
Select ports between 1 and 65535.
Select ports or port ranges between 1 and 65535.
</HelpText>
</div>
<div className={""}>
<PortSelector
showAll={direction == "bi"}
values={ports}
onChange={setPorts}
showAll={true}
ports={ports}
onPortsChange={setPorts}
portRanges={portRanges}
onPortRangesChange={setPortRanges}
disabled={portAndDirectionDisabled}
/>
</div>

View File

@@ -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<Props>) {
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 (
<div className={"flex-1"}>
@@ -35,7 +51,7 @@ export default function AccessControlPortsCell({ policy }: Props) {
<Tooltip delayDuration={1}>
<TooltipTrigger asChild={true}>
<div className={"inline-flex items-center gap-2"}>
{!hasPorts && (
{!hasAnyPorts && (
<Badge
variant={"gray"}
className={"uppercase tracking-wider font-medium"}
@@ -44,20 +60,19 @@ export default function AccessControlPortsCell({ policy }: Props) {
</Badge>
)}
{firstTwoPorts &&
firstTwoPorts.map((port) => {
return (
<Badge
key={port}
variant={"gray"}
className={
"px-3 gap-2 whitespace-nowrap uppercase tracking-wider font-medium"
}
>
{port}
</Badge>
);
})}
{firstTwoPorts?.map((port) => {
return (
<Badge
key={port}
variant={"gray"}
className={
"px-3 gap-2 whitespace-nowrap uppercase tracking-wider font-medium"
}
>
{port}
</Badge>
);
})}
{otherPorts && otherPorts.length > 0 && (
<Badge
@@ -73,9 +88,17 @@ export default function AccessControlPortsCell({ policy }: Props) {
</TooltipTrigger>
{otherPorts && otherPorts.length > 0 && (
<TooltipContent>
<div className={"flex flex-col gap-2 items-start mt-3 mb-2"}>
<div
className={
"flex gap-2 items-start mt-3 mb-2 flex-wrap max-w-sm"
}
>
{otherPorts.map((port) => {
return <Badge key={port}>{port}</Badge>;
return (
<Badge key={port} variant={"gray"}>
{port}
</Badge>
);
})}
</div>
</TooltipContent>

View File

@@ -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<PortRange[]>(() => {
if (!firstRule) return [];
if (firstRule.port_ranges == undefined) return [];
if (firstRule.port_ranges.length > 0) {
return firstRule.port_ranges;
}
return [];
});
const [protocol, setProtocol] = useState<Protocol>(
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];
};

View File

@@ -314,6 +314,7 @@ function RoutingPeerModalContent({
value={masquerade}
onChange={setMasquerade}
disabled={isNonLinuxRoutingPeer}
routingPeerGroupId={routingPeerGroups?.[0]?.id}
/>
<div className={cn("flex justify-between")}>

View File

@@ -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 (
<RoutingPeerMasqueradeTooltip show={disabled}>
<FancyToggleSwitch
value={value}
onChange={onChange}
disabled={disabled}
label={
<>
<VenetianMask size={15} />
Masquerade
</>
}
helpText={
"Allow access to your private networks without configuring routes on your local routers or other devices."
}
/>
<div className={"flex flex-col gap-4"}>
<FancyToggleSwitch
value={value}
onChange={onChange}
disabled={disabled}
label={
<>
<VenetianMask size={15} />
Masquerade
</>
}
helpText={
"Allow access to your private networks without configuring routes on your local routers or other devices."
}
/>
{routingPeerGroupId && !value && (
<RoutingPeerGroupNonLinuxWarning
routingPeerGroupId={routingPeerGroupId}
/>
)}
</div>
</RoutingPeerMasqueradeTooltip>
);
};
@@ -52,9 +69,51 @@ export const RoutingPeerMasqueradeTooltip = ({
delayDuration={250}
skipDelayDuration={350}
disabled={!show}
className={"cursor-help"}
className={cn(show && "cursor-help")}
>
{children}
</FullTooltip>
);
};
const RoutingPeerGroupNonLinuxWarning = ({
routingPeerGroupId,
}: {
routingPeerGroupId: string;
}) => {
const { groups } = useGroups();
const { data: peers } = useFetchApi<Peer[]>("/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 && (
<Callout
variant={"warning"}
icon={
<AlertCircleIcon
size={14}
className={"shrink-0 relative top-[3px] text-netbird"}
/>
}
>
Group <span className={"text-netbird font-normal"}>{group?.name}</span>{" "}
contains at least one non-Linux peer.
<br /> Disabled Masquerade will have no effect on non-Linux routing
peers.
</Callout>
)
);
};

View File

@@ -723,6 +723,7 @@ export function RouteModalContent({
value={masquerade}
onChange={setMasquerade}
disabled={isNonLinuxRoutingPeer}
routingPeerGroupId={routingPeerGroups?.[0]?.id}
/>
)}

View File

@@ -461,6 +461,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
value={masquerade}
onChange={setMasquerade}
disabled={isNonLinuxRoutingPeer}
routingPeerGroupId={routingPeerGroups?.[0]?.id}
/>
)}
<div className={cn("flex justify-between")}>

View File

@@ -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;

View File

@@ -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",