diff --git a/package-lock.json b/package-lock.json index f2f14ee1..e4d345e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3255,13 +3255,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -3665,9 +3665,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -6973,9 +6973,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" diff --git a/src/app/(dashboard)/events/proxy/page.tsx b/src/app/(dashboard)/events/proxy/page.tsx index 7316489f..d520a7aa 100644 --- a/src/app/(dashboard)/events/proxy/page.tsx +++ b/src/app/(dashboard)/events/proxy/page.tsx @@ -25,6 +25,8 @@ export default function ProxyEventsPage() { () => ({ start_date: dayjs().subtract(7, "day").startOf("day").toISOString(), end_date: dayjs().endOf("day").toISOString(), + sort_by: "timestamp", + sort_order: "desc", }), [], ); diff --git a/src/app/(dashboard)/network/page.tsx b/src/app/(dashboard)/network/page.tsx index c2ba2eab..f6962e94 100644 --- a/src/app/(dashboard)/network/page.tsx +++ b/src/app/(dashboard)/network/page.tsx @@ -12,7 +12,6 @@ import { } from "@components/DropdownMenu"; import FullTooltip from "@components/FullTooltip"; import InlineLink from "@components/InlineLink"; -import FullScreenLoading from "@components/ui/FullScreenLoading"; import useRedirect from "@hooks/useRedirect"; import useFetchApi from "@utils/api"; import { cn, singularize } from "@utils/helpers"; @@ -35,6 +34,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network"; import PageContainer from "@/layouts/PageContainer"; import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare"; +import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider"; import { NetworkProvider, useNetworksContext, @@ -49,6 +49,7 @@ import ReverseProxiesProvider, { flattenReverseProxies, useReverseProxies, } from "@/contexts/ReverseProxiesProvider"; +import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork"; export default function NetworkDetailPage() { const queryParameter = useSearchParams(); @@ -65,7 +66,7 @@ export default function NetworkDetailPage() { ) : ( - + ); } @@ -96,103 +97,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { return ( - -
- - } - /> - - + + +
+ + } + /> + + -
-
+
- -
- +
+ +
-
+
-
-
- +
+ +
-
- - - - - {singularize("Resources", network?.resources?.length)} - - - - {singularize("Routing Peers", network?.routing_peers_count)} - - - - {singularize("Services", services.length)} - - + + + + + {singularize("Resources", network?.resources?.length)} + + + + {singularize("Routing Peers", network?.routing_peers_count)} + + + + {singularize("Services", services.length)} + + - - - + + + - - - + + + - - - - -
+ + + + + +
); } diff --git a/src/components/HelpTooltip.tsx b/src/components/HelpTooltip.tsx index 3a85d427..2b066e38 100644 --- a/src/components/HelpTooltip.tsx +++ b/src/components/HelpTooltip.tsx @@ -1,29 +1,68 @@ import * as React from "react"; import FullTooltip from "@components/FullTooltip"; +import { HelpCircle } from "lucide-react"; +import { cn } from "@utils/helpers"; +import { TooltipVariants } from "@components/Tooltip"; type Props = { content: React.ReactNode; - children: React.ReactNode; + children?: React.ReactNode; interactive?: boolean; -}; + className?: string; + triggerClassName?: string; + align?: "start" | "center" | "end"; + side?: "top" | "right" | "bottom" | "left"; + alignOffset?: number; + sideOffset?: number; + iconSize?: number; + delayDuration?: number; +} & TooltipVariants; export const HelpTooltip = ({ content, children, - interactive = true, + interactive = false, + className, + variant = "default", + triggerClassName, + align = "start", + side = "top", + alignOffset = 0, + sideOffset, + iconSize = 12, + delayDuration = 300, }: Props) => { return ( <> {content}
+ } > - {children} + {children ? ( + children + ) : ( + + + + )} ); diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx index 2efb81a5..045d5117 100644 --- a/src/components/Notification.tsx +++ b/src/components/Notification.tsx @@ -18,6 +18,7 @@ export interface NotifyProps { icon?: React.ReactNode; backgroundColor?: string; preventSuccessToast?: boolean; + showOnlyError?: boolean; errorMessages?: ErrorResponse[]; } @@ -36,6 +37,7 @@ export default function Notification({ loadingMessage, duration = 3500, preventSuccessToast = false, + showOnlyError = false, errorMessages, }: NotificationProps) { const [error, setError] = useState(""); @@ -49,10 +51,13 @@ export default function Notification({ const startTimer = useCallback(() => { if (timerRef.current) return; startTimeRef.current = Date.now(); - timerRef.current = setTimeout(() => { - timerRef.current = null; - toast.dismiss(toastId); - }, Math.max(0, remainingRef.current)); + timerRef.current = setTimeout( + () => { + timerRef.current = null; + toast.dismiss(toastId); + }, + Math.max(0, remainingRef.current), + ); }, [toastId]); const pauseTimer = useCallback(() => { @@ -88,7 +93,10 @@ export default function Notification({ } }); - observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] }); + observer.observe(toastEl, { + attributes: true, + attributeFilter: ["data-expanded"], + }); // Start immediately if not expanded const expanded = toastEl.getAttribute("data-expanded") === "true"; @@ -106,7 +114,7 @@ export default function Notification({ promise .then(() => { setLoading(false); - if (preventSuccessToast) { + if (showOnlyError || preventSuccessToast) { toast.dismiss(toastId); } else { setReadyToDismiss(true); @@ -136,6 +144,9 @@ export default function Notification({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const hideUntilError = showOnlyError && loading && !error; + if (hideUntilError) return null; + return ( ) { const { data: resources, isLoading: isResourcesLoading } = useFetchApi< NetworkResource[] @@ -329,7 +334,7 @@ export function PeerGroupSelector({ "min-h-[46px] w-full relative items-center group", "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", - "disabled:pointer-events-none disabled:opacity-30 transition-all", + "disabled:pointer-events-none disabled:opacity-60 transition-all", )} disabled={disabled} data-cy={dataCy} @@ -343,7 +348,14 @@ export function PeerGroupSelector({ {resource && ( r.id === resource.id)} + resource={ + resources?.find((r) => r.id === resource.id) ?? + ({ + id: resource.id, + name: resource.id, + type: resource.type, + } as NetworkResource) + } peer={peers?.find((p) => p.id === resource.id)} onClick={(e) => { e.preventDefault(); @@ -569,12 +581,21 @@ export function PeerGroupSelector({ )} + {policies && ( + + )} +
{!users ? ( - + showPeerCounter && ( + + ) ) : ( { ) : null; }; +const PolicyCounter = ({ + group, + policies, +}: { + group: Group; + policies: Policy[]; +}) => { + const count = useMemo(() => { + if (!group.id) return 0; + return policies.filter((policy) => { + const destinations = policy.rules?.[0]?.destinations as + | (Group | string)[] + | undefined; + return destinations?.some((d) => + typeof d === "string" ? d === group.id : d.id === group.id, + ); + }).length; + }, [group.id, policies]); + + if (count === 0) return null; + + return ( +
+ + {count} {count === 1 ? "Policy" : "Policies"} +
+ ); +}; + const resourcesSearchPredicate = (item: NetworkResource, query: string) => { const lowerCaseQuery = query.toLowerCase(); if (item.name.toLowerCase().includes(lowerCaseQuery)) return true; diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 25502bf9..9fceafa2 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -75,8 +75,10 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + extra?: React.ReactNode; + } +>(({ className, children, extra, ...props }, ref) => ( {children} + {extra} )); SelectItem.displayName = SelectPrimitive.Item.displayName; diff --git a/src/components/skeletons/SkeletonNetwork.tsx b/src/components/skeletons/SkeletonNetwork.tsx new file mode 100644 index 00000000..33683252 --- /dev/null +++ b/src/components/skeletons/SkeletonNetwork.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { useEffect, useState } from "react"; +import Skeleton from "react-loading-skeleton"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; + +export const SkeletonNetwork = ({ delay = 400 }: { delay?: number }) => { + const [show, setShow] = useState(delay === 0); + + useEffect(() => { + if (delay === 0) return; + const timer = setTimeout(() => setShow(true), delay); + return () => clearTimeout(timer); + }, [delay]); + + if (!show) return null; + + return ( +
+ +
+ + +
+
+ +
+
+ + + +
+ +
+ + +
+
+ +
+
+ ); +}; diff --git a/src/components/table/DataTable.tsx b/src/components/table/DataTable.tsx index def12aa3..7005fd7b 100644 --- a/src/components/table/DataTable.tsx +++ b/src/components/table/DataTable.tsx @@ -296,6 +296,7 @@ export function DataTable({ autoResetAll: false, autoResetExpanded: false, manualPagination: manualPagination, + manualSorting: serverSidePagination, manualFiltering: manualFiltering || manualColumnFiltering, pageCount: pageCount, state: { diff --git a/src/components/table/DataTableHeader.tsx b/src/components/table/DataTableHeader.tsx index f09c8b6f..2d7e450e 100644 --- a/src/components/table/DataTableHeader.tsx +++ b/src/components/table/DataTableHeader.tsx @@ -5,6 +5,7 @@ import { IconSortAscending, IconSortDescending } from "@tabler/icons-react"; import type { Column } from "@tanstack/table-core"; import { cn } from "@utils/helpers"; import React from "react"; +import { useOptionalServerPagination } from "@/contexts/ServerPaginationProvider"; type Props = { column: Column; @@ -13,6 +14,7 @@ type Props = { center?: boolean; className?: string; sorting?: boolean; + name?: string; }; export default function DataTableHeader({ children, @@ -21,15 +23,22 @@ export default function DataTableHeader({ center, className, sorting = true, + name, }: Props) { + const serverPagination = useOptionalServerPagination(); + + const handleSort = () => { + const direction = column.getIsSorted() === "asc" ? "desc" : "asc"; + column.toggleSorting(direction === "desc"); + if (name && serverPagination?.setSort) { + serverPagination.setSort(name, direction); + } + }; + return (
column.toggleSorting(column.getIsSorted() === "asc") - : undefined - } + onClick={sorting ? handleSort : undefined} className={cn( "flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide", sorting && diff --git a/src/components/ui/MultipleGroups.tsx b/src/components/ui/MultipleGroups.tsx index bd190daf..5083248b 100644 --- a/src/components/ui/MultipleGroups.tsx +++ b/src/components/ui/MultipleGroups.tsx @@ -26,6 +26,7 @@ type Props = { showResources?: boolean; redirectGroupTab?: string; showUsers?: boolean; + disableRedirect?: boolean; }; export default function MultipleGroups({ @@ -37,6 +38,7 @@ export default function MultipleGroups({ showResources = false, showUsers = false, redirectGroupTab, + disableRedirect = false, }: Readonly) { const { permission } = usePermissions(); @@ -64,6 +66,7 @@ export default function MultipleGroups({ {firstGroup && ( {showResources ? ( - + ) : showUsers ? ( ) : ( - + )}
) diff --git a/src/components/ui/PeerCountBadge.tsx b/src/components/ui/PeerCountBadge.tsx index f8fa4ce5..0864852a 100644 --- a/src/components/ui/PeerCountBadge.tsx +++ b/src/components/ui/PeerCountBadge.tsx @@ -10,6 +10,7 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge"; type Props = { group?: Group; + disableRedirect?: boolean; } & React.HTMLAttributes & BadgeVariants; @@ -17,6 +18,7 @@ export default function PeerCountBadge({ group, variant = "gray", className, + disableRedirect = false, }: Props) { const router = useRouter(); const { dropdownOptions, groups } = useGroups(); @@ -35,7 +37,8 @@ export default function PeerCountBadge({ return peerCount; }, [currentGroup]); - const canRedirect = !!group?.id && group?.name !== "All"; + const canRedirect = + !!group?.id && group?.name !== "All" && !disableRedirect; const onClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -46,7 +49,7 @@ export default function PeerCountBadge({ const showResources = resourcesCount > 0 && peerCount === 0; return showResources ? ( - + ) : ( & BadgeVariants; -export default function ResourceCountBadge({ group }: Props) { +export default function ResourceCountBadge({ + group, + disableRedirect = false, +}: Props) { const router = useRouter(); const hasId = !!group?.id; const onClick = (e: React.MouseEvent) => { e.stopPropagation(); + if (disableRedirect) return; if (hasId) router.push(`/group?id=${group?.id}&tab=resources`); }; diff --git a/src/contexts/DialogProvider.tsx b/src/contexts/DialogProvider.tsx index a4fdbd33..64688ba6 100644 --- a/src/contexts/DialogProvider.tsx +++ b/src/contexts/DialogProvider.tsx @@ -27,6 +27,8 @@ type DialogOptions = { type?: "default" | "warning" | "danger" | "center"; children?: React.ReactNode; maxWidthClass?: string; + hideIcon?: boolean; + center?: boolean; }; export default function DialogProvider({ children }: Props) { @@ -70,14 +72,14 @@ export default function DialogProvider({ children }: Props) { onPointerDownOutside={(e) => e.preventDefault()} > void; createPolicy: (policy: Policy) => Promise; + createPoliciesForResource: ( + policies: Policy[], + resource: NetworkResource, + ) => Promise; openEditPolicyModal: (policy: Policy, tab?: string) => void; + deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise; + serializeRules: ( + rules: Policy["rules"], + enabled?: boolean, + ) => Policy["rules"]; }, ); export default function PoliciesProvider({ children }: Props) { + const { mutate } = useSWRConfig(); const request = useApiCall("/policies"); + const { createOrUpdate: createOrUpdateGroup } = useGroups(); const [policyModal, setPolicyModal] = useState(false); const [currentPolicy, setCurrentPolicy] = useState(); const [initialPolicyTab, setInitialPolicyTab] = useState(""); const createPolicy = async (policy: Policy) => request.post(policy); + const createPolicyForResource = async ( + policy: Policy, + resource: NetworkResource, + ) => { + const rule = policy.rules[0]; + + const sources = await Promise.all( + (rule.sources ?? []).map((g) => { + if (typeof g === "string") return g; + if (g.id) return g.id; + return createOrUpdateGroup(g).then((r) => r.id); + }), + ).then((ids) => ids.filter(Boolean) as string[]); + + const hasGroups = resource.groups && resource.groups.length > 0; + + const destinations = hasGroups + ? await Promise.all( + (resource.groups as (Group | string)[]).map((g) => { + if (typeof g === "string") return g; + if (g.id) return g.id; + return createOrUpdateGroup(g).then((r) => r.id); + }), + ).then((ids) => ids.filter(Boolean) as string[]) + : null; + + return createPolicy({ + ...policy, + source_posture_checks: (policy.source_posture_checks ?? []).map((c) => + typeof c === "string" ? c : c.id, + ), + rules: [ + { + ...rule, + sources, + destinations, + destinationResource: hasGroups + ? undefined + : { id: resource.id, type: resource.type }, + }, + ], + } as Policy); + }; + + const createPoliciesForResource = async ( + newPolicies: Policy[], + resource: NetworkResource, + ) => { + const policiesToCreate = newPolicies.filter((p) => !p.id); + if (policiesToCreate.length === 0) return; + + const promise = Promise.all( + policiesToCreate.map((p) => createPolicyForResource(p, resource)), + ).then(() => mutate("/policies")); + + notify({ + title: "Create Policies", + description: "Successfully created policies for resource.", + promise, + showOnlyError: true, + }); + + return promise; + }; + + const serializeRules = (rules: Policy["rules"], enabled?: boolean) => { + rules = cloneDeep(rules); + rules.forEach((rule) => { + if (enabled !== undefined) rule.enabled = enabled; + rule.sources = rule.sources + ? (rule.sources.map((s) => { + const group = s as Group; + return group.id ?? s; + }) as string[]) + : []; + rule.destinations = rule.destinations + ? (rule.destinations.map((d) => { + const group = d as Group; + return group.id ?? d; + }) as string[]) + : []; + if (rule.destinationResource) rule.destinations = null; + if (rule.sourceResource) rule.sources = null; + }); + return rules; + }; + const updatePolicy = async ( policy: Policy, toUpdate: Partial, @@ -62,6 +165,20 @@ export default function PoliciesProvider({ children }: Props) { }); }; + const deletePolicy = async (policy: Policy, onSuccess?: () => void) => { + const promise = request.del("", `/${policy.id}`).then(() => { + mutate("/policies"); + onSuccess?.(); + }); + notify({ + title: "Access Control Policy " + policy.name, + description: "The policy was successfully deleted.", + promise, + loadingMessage: "Deleting policy...", + }); + return promise; + }; + const openEditPolicyModal = (policy: Policy, tab?: string) => { setCurrentPolicy(policy); tab && setInitialPolicyTab(tab); @@ -70,7 +187,14 @@ export default function PoliciesProvider({ children }: Props) { return ( {children} = { onGlobalFilterChange: (value: string) => void; setFilter: (key: string, value: string | undefined) => void; getFilter: (key: string) => string | undefined; + setSort: (name: string, direction: "asc" | "desc") => void; hasActiveFilters: boolean; resetFilters: () => void; onFilterReset: () => void; @@ -146,6 +147,15 @@ export default function ServerPaginationProvider({ const getFilter = useCallback((key: string) => filters[key], [filters]); + const setSort = useCallback((name: string, direction: "asc" | "desc") => { + setFilters((prev) => ({ + ...prev, + sort_by: name, + sort_order: direction, + })); + setPage(1); + }, []); + const hasActiveFilters = search !== "" || Object.entries(filters).some( @@ -170,6 +180,7 @@ export default function ServerPaginationProvider({ mutate, setFilter, getFilter, + setSort, hasActiveFilters, resetFilters, pagination: { pageIndex: page - 1, pageSize }, @@ -193,6 +204,7 @@ export default function ServerPaginationProvider({ mutate, setFilter, getFilter, + setSort, hasActiveFilters, resetFilters, page, @@ -220,3 +232,8 @@ export function useServerPagination() { } return context as ServerPaginationContextValue; } + +export function useOptionalServerPagination() { + const context = useContext(ServerPaginationContext); + return context as ServerPaginationContextValue | null; +} diff --git a/src/hooks/useUrlTab.ts b/src/hooks/useUrlTab.ts index a02ea3ce..0d228fd6 100644 --- a/src/hooks/useUrlTab.ts +++ b/src/hooks/useUrlTab.ts @@ -1,26 +1,37 @@ -import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useMemo } from "react"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; export default function useUrlTab( validTabs: string[], defaultTab: string, ): [string, (value: string) => void] { const searchParams = useSearchParams(); - const router = useRouter(); - const tab = useMemo(() => { - const tabParam = searchParams.get("tab"); - if (tabParam && validTabs.includes(tabParam)) return tabParam; - return defaultTab; - }, [searchParams, validTabs, defaultTab]); + const getTab = useCallback( + (params: URLSearchParams) => { + const tabParam = params.get("tab"); + if (tabParam && validTabs.includes(tabParam)) return tabParam; + return defaultTab; + }, + [validTabs, defaultTab], + ); + + const [tab, setTabState] = useState(() => getTab(searchParams)); + + useEffect(() => { + const newTab = getTab(searchParams); + setTabState(newTab); + }, [searchParams, getTab]); const setTab = useCallback( (value: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set("tab", value); - router.replace(`?${params.toString()}`, { scroll: false }); + const nextTab = validTabs.includes(value) ? value : defaultTab; + setTabState(nextTab); + const params = new URLSearchParams(window.location.search); + params.set("tab", nextTab); + window.history.replaceState(null, "", `?${params.toString()}`); }, - [searchParams, router], + [validTabs, defaultTab], ); return [tab, setTab]; diff --git a/src/interfaces/ReverseProxy.ts b/src/interfaces/ReverseProxy.ts index 80eb0fbc..77d5dde9 100644 --- a/src/interfaces/ReverseProxy.ts +++ b/src/interfaces/ReverseProxy.ts @@ -26,6 +26,15 @@ export enum ReverseProxyStatus { ERROR = "error", } +export type ServiceTargetOptionsPathRewrite = "preserve"; + +export interface ServiceTargetOptions { + skip_tls_verify?: boolean; + request_timeout?: string; + path_rewrite?: ServiceTargetOptionsPathRewrite; + custom_headers?: Record; +} + export interface ReverseProxyTarget { target_id?: string; target_type: ReverseProxyTargetType; @@ -35,6 +44,7 @@ export interface ReverseProxyTarget { port: number; enabled: boolean; access_local?: boolean; + options?: ServiceTargetOptions; // Frontend destination?: string; } diff --git a/src/modules/access-control/AccessControlModal.tsx b/src/modules/access-control/AccessControlModal.tsx index d2de196b..759476e4 100644 --- a/src/modules/access-control/AccessControlModal.tsx +++ b/src/modules/access-control/AccessControlModal.tsx @@ -54,6 +54,7 @@ import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheck import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType"; import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups"; import { useUsers } from "@/contexts/UsersProvider"; +import { HelpTooltip } from "@components/HelpTooltip"; type Props = { children?: React.ReactNode; @@ -124,6 +125,7 @@ type ModalProps = { initialPorts?: number[]; initialDestinationResource?: PolicyRuleResource; initialTab?: string; + disableDestinationSelector?: boolean; }; export function AccessControlModalContent({ @@ -140,6 +142,7 @@ export function AccessControlModalContent({ initialPorts, initialDestinationResource, initialTab, + disableDestinationSelector = false, }: Readonly) { const { permission } = usePermissions(); const { users } = useUsers(); @@ -293,7 +296,25 @@ export function AccessControlModalContent({ TCP UDP ICMP - NetBird SSH + + Select NetBird SSH for SSH-specific policies with + fine-grained access control, or use TCP with port 22 + for basic network-level SSH access + + } + /> + } + > + NetBird SSH +
@@ -303,6 +324,15 @@ export function AccessControlModalContent({ Destination + + Typically a group of peers or resources (e.g., Servers, + Databases, Internal Services) that will be accessed by + the source. Can also be an individual peer or resource. + + } + /> @@ -575,7 +616,13 @@ export function AccessControlModalContent({ - - )} - - } - columnVisibility={{ - description: false, - id: false, - }} - paginationPaddingClassName={"px-0 pt-8"} - > - {(table) => ( - - )} - - + + + ( + mutate("/networks/resources")} + onResourceDelete={() => mutate("/networks/resources")} + > + {children} + + )} + inset={false} + tableClassName={"mt-0"} + text={"Resources"} + columns={GroupResourcesColumns} + keepStateInLocalStorage={false} + data={resources} + searchPlaceholder={"Search by name, address or group..."} + getStartedCard={ + } + > + {permission?.networks?.create && ( + <> + + + )} + + } + columnVisibility={{ + description: false, + id: false, + }} + paginationPaddingClassName={"px-0 pt-8"} + > + {(table) => ( + + )} + + + ); }; diff --git a/src/modules/networks/NetworkAccessControlProvider.tsx b/src/modules/networks/NetworkAccessControlProvider.tsx new file mode 100644 index 00000000..a2cf11f8 --- /dev/null +++ b/src/modules/networks/NetworkAccessControlProvider.tsx @@ -0,0 +1,151 @@ +import useFetchApi from "@utils/api"; +import { orderBy } from "lodash"; +import * as React from "react"; +import { useCallback, useContext } from "react"; +import { Group } from "@/interfaces/Group"; +import { NetworkResource } from "@/interfaces/Network"; +import { Policy } from "@/interfaces/Policy"; + +type NetworkAccessControlContextValue = { + policies?: Policy[]; + policiesLoading: boolean; + resources?: NetworkResource[]; + assignedPolicies: ( + resource?: NetworkResource, + groups?: Group[], + ) => { + policies: Policy[]; + enabledPolicies: Policy[]; + isLoading: boolean; + policyCount: number; + }; + resourceExists: (name: string, excludeId?: string) => boolean; + getPolicyDestinationResources: (policy: Policy) => NetworkResource[]; +}; + +const NetworkAccessControlContext = + React.createContext(null); + +type Props = { + children: React.ReactNode; +}; + +const toGroupId = (g: Group | string): string | undefined => + typeof g === "string" ? g : g?.id; + +export const NetworkAccessControlProvider = ({ children }: Props) => { + const { data: policies, isLoading: policiesLoading } = + useFetchApi("/policies"); + const { data: resources } = useFetchApi( + "/networks/resources", + ); + + const resourceExists = useCallback( + (name: string, excludeId?: string) => { + if (!name) return false; + return !!resources?.find( + (r) => + r.name.toLowerCase() === name.toLowerCase() && r.id !== excludeId, + ); + }, + [resources], + ); + + const assignedPolicies = useCallback( + (resource?: NetworkResource, groups?: Group[]) => { + const resourceGroups = (groups || resource?.groups) as + | (Group | string)[] + | undefined; + if (!resource && !resourceGroups?.length) { + return { + policies: [], + enabledPolicies: [], + isLoading: policiesLoading, + policyCount: 0, + }; + } + const resourceGroupIds = new Set( + resourceGroups?.map(toGroupId).filter(Boolean), + ); + const resourcePolicies = orderBy( + policies?.filter((policy) => { + const rule = policy.rules?.[0]; + if (!rule) return false; + if (resource && rule.destinationResource?.id === resource.id) + return true; + const destinations = (rule.destinations ?? []) as (Group | string)[]; + return destinations.some((d) => { + const destId = toGroupId(d); + return !!destId && resourceGroupIds.has(destId); + }); + }), + "enabled", + "desc", + ); + const enabledPolicies = resourcePolicies?.filter( + (policy) => policy?.enabled, + ); + return { + policies: resourcePolicies, + enabledPolicies, + isLoading: policiesLoading, + policyCount: resourcePolicies?.length || 0, + }; + }, + [policies, policiesLoading], + ); + + const getPolicyDestinationResources = useCallback( + (policy: Policy): NetworkResource[] => { + const rule = policy?.rules?.[0]; + const destinationGroups = rule?.destinations as + | (Group | string)[] + | undefined; + const destinationGroupIds = new Set( + destinationGroups?.map(toGroupId).filter(Boolean), + ); + const directDestinationId = rule?.destinationResource?.id; + + return ( + resources?.filter((resource) => { + if (directDestinationId && resource.id === directDestinationId) + return true; + const resourceGroups = resource.groups as + | (Group | string)[] + | undefined; + return resourceGroups?.some((g) => { + const groupId = toGroupId(g); + return !!groupId && destinationGroupIds.has(groupId); + }); + }) ?? [] + ); + }, + [resources], + ); + + return ( + + {children} + + ); +}; + +export const useNetworkAccessControl = + (): NetworkAccessControlContextValue => { + const context = useContext(NetworkAccessControlContext); + if (!context) { + throw new Error( + "useNetworkAccessControl must be used within a NetworkAccessControlProvider", + ); + } + return context; + }; diff --git a/src/modules/networks/NetworkProvider.tsx b/src/modules/networks/NetworkProvider.tsx index a7d82555..7250bc9c 100644 --- a/src/modules/networks/NetworkProvider.tsx +++ b/src/modules/networks/NetworkProvider.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { useState } from "react"; import { useSWRConfig } from "swr"; import { useDialog } from "@/contexts/DialogProvider"; +import { useNetworkAccessControl } from "@/modules/networks/NetworkAccessControlProvider"; import { Group } from "@/interfaces/Group"; import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network"; import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal"; @@ -14,6 +15,9 @@ import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupMo import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal"; import { Policy, PolicyRuleResource } from "@/interfaces/Policy"; import PoliciesProvider from "@/contexts/PoliciesProvider"; +import { ResourceIcon } from "@/assets/icons/ResourceIcon"; +import CopyToClipboardText from "@components/CopyToClipboardText"; +import { cn } from "@utils/helpers"; type Props = { children: React.ReactNode; @@ -27,7 +31,11 @@ const NetworksContext = React.createContext( openAddRoutingPeerModal: (network: Network, router?: NetworkRouter) => void; openEditNetworkModal: (network: Network) => void; openCreateNetworkModal: () => void; - openResourceModal: (network: Network, resource?: NetworkResource) => void; + openResourceModal: ( + network: Network, + resource?: NetworkResource, + initialTab?: string, + ) => void; openResourceGroupModal: ( network: Network, resource?: NetworkResource, @@ -38,6 +46,24 @@ const NetworksContext = React.createContext( deleteResource: (network: Network, resource: NetworkResource) => void; deleteRouter: (network: Network, router: NetworkRouter) => void; network?: Network; + assignedPolicies: ( + resource?: NetworkResource, + groups?: Group[], + ) => { + policies: Policy[]; + enabledPolicies: Policy[]; + isLoading: boolean; + policyCount: number; + }; + resourceExists: (name: string, excludeId?: string) => boolean; + resources?: NetworkResource[]; + getPolicyDestinationResources: (policy: Policy) => NetworkResource[]; + confirmMultiResourceAction: ( + policy: Policy, + action: "edit" | "delete", + additionalResource?: NetworkResource, + ) => Promise; + policies?: Policy[]; }, ); @@ -50,6 +76,13 @@ export const NetworkProvider = ({ const { mutate } = useSWRConfig(); const { confirm } = useDialog(); const deleteCall = useApiCall("/networks").del; + const { + policies, + resources, + assignedPolicies, + resourceExists, + getPolicyDestinationResources, + } = useNetworkAccessControl(); const [currentNetwork, setCurrentNetwork] = useState(); const [currentResource, setCurrentResource] = useState(); @@ -88,9 +121,18 @@ export const NetworkProvider = ({ setNetworkModal(true); }; - const openResourceModal = (network: Network, resource?: NetworkResource) => { + const [resourceModalInitialTab, setResourceModalInitialTab] = useState< + string | undefined + >(); + + const openResourceModal = ( + network: Network, + resource?: NetworkResource, + initialTab?: string, + ) => { setCurrentNetwork(network); resource && setCurrentResource(resource); + setResourceModalInitialTab(initialTab); setResourceModal(true); }; @@ -110,11 +152,11 @@ export const NetworkProvider = ({ destinationResource: hasResourceGroups ? undefined : resource - ? ({ - id: resource.id, - type: resource.type, - } as PolicyRuleResource) - : undefined, + ? ({ + id: resource.id, + type: resource.type, + } as PolicyRuleResource) + : undefined, name: network && !resource ? `${network?.name} Policy` @@ -138,6 +180,45 @@ export const NetworkProvider = ({ setPolicyModal(true); }; + const confirmMultiResourceAction = async ( + policy: Policy, + action: "edit" | "delete", + additionalResource?: NetworkResource, + ) => { + const fetchedResources = getPolicyDestinationResources(policy); + const affectedResources = + additionalResource && + !fetchedResources.some((r) => r.id === additionalResource.id) + ? [...fetchedResources, additionalResource] + : fetchedResources; + const isMulti = affectedResources.length > 1; + return confirm({ + title: isMulti ? ( + <>This policy is used by multiple resources + ) : ( + <> + {action === "edit" ? "Edit" : "Delete"} policy '{policy.name} + '? + + ), + description: isMulti + ? `This policy uses one or many resource group(s) as destinations. ${ + action === "edit" ? "Updating" : "Deleting" + } this policy will also affect following resources:` + : action === "delete" + ? "Are you sure you want to delete this policy? This action cannot be undone." + : undefined, + children: isMulti ? ( + + ) : undefined, + confirmText: action === "edit" ? "Edit Policy" : "Delete Policy", + cancelText: "Cancel", + hideIcon: isMulti, + type: action === "edit" ? "warning" : "danger", + maxWidthClass: isMulti ? "max-w-lg" : undefined, + }); + }; + const deleteNetwork = async (network: Network) => { const choice = await confirm({ title: `Delete network '${network.name}'?`, @@ -244,19 +325,6 @@ export const NetworkProvider = ({ openResourceModal(network); }; - const askForAccessControlPolicy = async (res: NetworkResource) => { - const choice = await confirm({ - title: `Add policy for '${res.name}'?`, - description: - "Without a policy, the resource will not be accessible by any peers. Create a policy to control access to this resource.", - confirmText: "Create Policy", - cancelText: "Later", - type: "default", - }); - if (!choice) return; - openPolicyModal(currentNetwork, res); - }; - return ( - {children} - - { - mutate("/networks"); - await askForResource(network); - }} - onUpdated={(n) => { - mutate("/networks"); - mutate(`/networks/${n.id}`); - }} - /> + {children} + + { + mutate("/networks"); + await askForResource(network); + }} + onUpdated={(n) => { + mutate("/networks"); + mutate(`/networks/${n.id}`); + }} + /> { @@ -321,93 +395,99 @@ export const NetworkProvider = ({ }} /> + {currentNetwork && ( + <> + { + setRoutingPeerModal(false); + setCurrentRouter(undefined); + mutate(`/networks`); + mutate("/groups"); + if (network) { + mutate(`/networks/${currentNetwork.id}/routers`); + mutate(`/networks/${network.id}`); + } + }} + onUpdated={async () => { + setRoutingPeerModal(false); + setCurrentRouter(undefined); + mutate(`/networks`); + mutate("/groups"); + if (network) { + mutate(`/networks/${network.id}`); + mutate(`/networks/${currentNetwork.id}/routers`); + } + }} + setOpen={(state) => { + setCurrentRouter(undefined); + setRoutingPeerModal(state); + }} + /> + + { + setCurrentResource(undefined); + setResourceGroupModal(state); + }} + onUpdated={() => { + setResourceGroupModal(false); + setCurrentResource(undefined); + mutate("/groups"); + mutate("/networks/resources"); + if (network) { + onResourceUpdate?.(); + mutate(`/networks/${network.id}/resources`); + mutate(`/networks/${network.id}`); + } + }} + /> + + { + setResourceModal(false); + setCurrentResource(undefined); + mutate("/networks"); + mutate("/groups"); + mutate("/networks/resources"); + if (network) { + mutate(`/networks/${network.id}/resources`); + mutate(`/networks/${network.id}`); + } else { + currentNetwork?.routing_peers_count === 0 && + (await askForRoutingPeer(currentNetwork)); + } + }} + onUpdated={() => { + setResourceModal(false); + setCurrentResource(undefined); + mutate("/networks"); + mutate("/groups"); + mutate("/networks/resources"); + if (network) { + onResourceUpdate?.(); + mutate(`/networks/${network.id}/resources`); + mutate(`/networks/${network.id}`); + } + }} + open={resourceModal} + setOpen={(state) => { + setCurrentResource(undefined); + setResourceModalInitialTab(undefined); + setResourceModal(state); + }} + /> + + )} - {currentNetwork && ( - <> - { - setRoutingPeerModal(false); - setCurrentRouter(undefined); - mutate(`/networks`); - mutate("/groups"); - if (network) { - mutate(`/networks/${currentNetwork.id}/routers`); - mutate(`/networks/${network.id}`); - } - }} - onUpdated={async () => { - setRoutingPeerModal(false); - setCurrentRouter(undefined); - mutate(`/networks`); - mutate("/groups"); - if (network) { - mutate(`/networks/${network.id}`); - mutate(`/networks/${currentNetwork.id}/routers`); - } - }} - setOpen={(state) => { - setCurrentRouter(undefined); - setRoutingPeerModal(state); - }} - /> - - { - setCurrentResource(undefined); - setResourceGroupModal(state); - }} - onUpdated={() => { - setResourceGroupModal(false); - setCurrentResource(undefined); - mutate("/groups"); - if (network) { - onResourceUpdate?.(); - mutate(`/networks/${network.id}/resources`); - mutate(`/networks/${network.id}`); - } - }} - /> - - { - setResourceModal(false); - setCurrentResource(undefined); - mutate("/networks"); - mutate("/groups"); - if (network) { - mutate(`/networks/${network.id}/resources`); - mutate(`/networks/${network.id}`); - } else { - await askForAccessControlPolicy(r); - } - }} - onUpdated={() => { - setResourceModal(false); - setCurrentResource(undefined); - mutate("/networks"); - mutate("/groups"); - if (network) { - onResourceUpdate?.(); - mutate(`/networks/${network.id}/resources`); - mutate(`/networks/${network.id}`); - } - }} - open={resourceModal} - setOpen={(state) => { - setCurrentResource(undefined); - setResourceModal(state); - }} - /> - - )} ); }; @@ -419,3 +499,37 @@ export const useNetworksContext = () => { } return context; }; + +function AffectedResourceList({ resources }: { resources: NetworkResource[] }) { + const maxVisible = 6; + const visible = resources.slice(0, maxVisible); + const remaining = resources.length - maxVisible; + return ( +
+ {visible.map((r, i) => ( +
0 && "border-t border-nb-gray-900", + )} + > + + {r.name} + + {r.address} + +
+ ))} + {remaining > 0 && ( +
+ + {remaining} more +
+ )} +
+ ); +} diff --git a/src/modules/networks/resources/NetworkResourceAccessControl.tsx b/src/modules/networks/resources/NetworkResourceAccessControl.tsx new file mode 100644 index 00000000..8d0f0d97 --- /dev/null +++ b/src/modules/networks/resources/NetworkResourceAccessControl.tsx @@ -0,0 +1,307 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Label } from "@components/Label"; +import { Modal } from "@components/modal/Modal"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import { Edit2, MoreVertical, PlusIcon, Trash2 } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { NetworkResource } from "@/interfaces/Network"; +import { Policy, PolicyRuleResource } from "@/interfaces/Policy"; +import { cn } from "@utils/helpers"; +import { usePolicies } from "@/contexts/PoliciesProvider"; +import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell"; +import { useNetworksContext } from "@/modules/networks/NetworkProvider"; +import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal"; +import CircleIcon from "@/assets/icons/CircleIcon"; +import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell"; +import AccessControlPortsCell from "@/modules/access-control/table/AccessControlPortsCell"; +import TruncatedText from "@components/ui/TruncatedText"; + +type Props = { + existingPolicies: Policy[]; + newPolicies: Policy[]; + onNewPoliciesChange: (policies: Policy[]) => void; + address: string; + resourceName?: string; + resourceId?: string; + hasResourceGroups?: boolean; +}; + +function getResourceType(address: string): "domain" | "host" | "subnet" { + const hasChars = !!address.match(/[a-z*]/i); + const isCIDR = !!address.match(/\//); + return hasChars ? "domain" : isCIDR ? "subnet" : "host"; +} + +export default function NetworkResourceAccessControl({ + existingPolicies, + newPolicies, + onNewPoliciesChange, + address, + resourceName, + resourceId, + hasResourceGroups = false, +}: Readonly) { + const { network, confirmMultiResourceAction } = useNetworksContext(); + const { openEditPolicyModal, deletePolicy } = usePolicies(); + const [policyModalOpen, setPolicyModalOpen] = useState(false); + const [editingPolicyIndex, setEditingPolicyIndex] = useState( + null, + ); + + const allPolicies = useMemo( + () => [...existingPolicies, ...newPolicies], + [existingPolicies, newPolicies], + ); + + const destinationResource: PolicyRuleResource = useMemo(() => { + return { + id: resourceId || resourceName || address, + type: getResourceType(address), + }; + }, [address, resourceName, resourceId]); + + const currentResource = useMemo(() => { + return { + id: resourceId || "", + name: resourceName || address, + address, + type: getResourceType(address), + enabled: true, + }; + }, [resourceId, resourceName, address]); + + const openAddPolicy = () => { + setEditingPolicyIndex(null); + setPolicyModalOpen(true); + }; + + const openEditPolicy = async (policy: Policy) => { + if (policy.id) { + const confirm = await confirmMultiResourceAction( + policy, + "edit", + currentResource, + ); + if (!confirm) return; + openEditPolicyModal(policy); + } else { + const idx = newPolicies.indexOf(policy); + if (idx === -1) return; + setEditingPolicyIndex(idx); + setPolicyModalOpen(true); + } + }; + + const savePolicy = (policy: Policy) => { + if (editingPolicyIndex !== null) { + onNewPoliciesChange( + newPolicies.map((p, i) => (i === editingPolicyIndex ? policy : p)), + ); + } else { + onNewPoliciesChange([...newPolicies, policy]); + } + }; + + const handleDeletePolicy = async (policy: Policy) => { + const confirm = await confirmMultiResourceAction( + policy, + "delete", + currentResource, + ); + if (!confirm) return; + if (policy.id) { + await deletePolicy(policy); + } else { + onNewPoliciesChange(newPolicies.filter((p) => p !== policy)); + } + }; + + return ( +
+
+ + + Define which source groups are allowed to access this resource. You + can also restrict access to specific protocols and ports. Without + policies access to this resource will not be possible. + + + {allPolicies.length > 0 && ( +
+ + + + + + + + + + {allPolicies.map((policy, index) => { + return ( + openEditPolicy(policy)} + className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all" + > + + + + + + + ); + })} + +
+ Name + + Source Groups + + Protocol & Ports + +
+
+
+ +
+
+ + {policy.description && ( +
+ +
+ )} +
+
+
+ + +
+ + +
+
+
e.stopPropagation()} + > + + + + + + openEditPolicy(policy)} + > +
+ + Edit Policy +
+
+ handleDeletePolicy(policy)} + > +
+ + Delete Policy +
+
+
+
+
+
+
+ )} + + +
+ + { + setPolicyModalOpen(open); + if (!open) setEditingPolicyIndex(null); + }} + key={policyModalOpen ? 1 : 0} + > + { + savePolicy(policy); + setPolicyModalOpen(false); + setEditingPolicyIndex(null); + }} + /> + +
+ ); +} diff --git a/src/modules/networks/resources/NetworkResourceModal.tsx b/src/modules/networks/resources/NetworkResourceModal.tsx index 6f7a6efc..a238e18b 100644 --- a/src/modules/networks/resources/NetworkResourceModal.tsx +++ b/src/modules/networks/resources/NetworkResourceModal.tsx @@ -1,9 +1,10 @@ "use client"; import Button from "@components/Button"; +import { Callout } from "@components/Callout"; import FancyToggleSwitch from "@components/FancyToggleSwitch"; import HelpText from "@components/HelpText"; -import InlineLink from "@components/InlineLink"; +import InlineLink, { InlineButtonLink } from "@components/InlineLink"; import { Input } from "@components/Input"; import { Label } from "@components/Label"; import { @@ -15,18 +16,27 @@ import { import ModalHeader from "@components/modal/ModalHeader"; import { notify } from "@components/Notification"; import Paragraph from "@components/Paragraph"; +import { HelpTooltip } from "@components/HelpTooltip"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; -import Separator from "@components/Separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import { useApiCall } from "@utils/api"; +import { useDialog } from "@/contexts/DialogProvider"; +import { usePolicies } from "@/contexts/PoliciesProvider"; +import { useNetworksContext } from "@/modules/networks/NetworkProvider"; import { ExternalLinkIcon, PlusCircle, Power, + ShieldCheck, + Text, WorkflowIcon, } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; +import { Group } from "@/interfaces/Group"; import { Network, NetworkResource } from "@/interfaces/Network"; +import { Policy } from "@/interfaces/Policy"; import useGroupHelper from "@/modules/groups/useGroupHelper"; +import NetworkResourceAccessControl from "@/modules/networks/resources/NetworkResourceAccessControl"; import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput"; type Props = { @@ -36,6 +46,7 @@ type Props = { resource?: NetworkResource; onCreated?: (r: NetworkResource) => void; onUpdated?: (r: NetworkResource) => void; + initialTab?: string; }; export default function NetworkResourceModal({ @@ -45,6 +56,7 @@ export default function NetworkResourceModal({ resource, onUpdated, onCreated, + initialTab, }: Props) { return ( @@ -54,6 +66,7 @@ export default function NetworkResourceModal({ resource={resource} onCreated={onCreated} onUpdated={onUpdated} + initialTab={initialTab} /> ); @@ -64,6 +77,7 @@ type ModalProps = { onUpdated?: (r: NetworkResource) => void; network: Network; resource?: NetworkResource; + initialTab?: string; }; export function ResourceModalContent({ @@ -71,6 +85,7 @@ export function ResourceModalContent({ onUpdated, network, resource, + initialTab, }: ModalProps) { const create = useApiCall( `/networks/${network.id}/resources`, @@ -88,50 +103,123 @@ export function ResourceModalContent({ const [enabled, setEnabled] = useState( resource ? resource.enabled : true, ); + const nameRef = useRef(null); + const [tab, setTab] = useState(initialTab || "resource"); + const [addressError, setAddressError] = useState(""); + + const { confirm } = useDialog(); + + // Access control policies + const [policies, setPolicies] = useState([]); + const { createPoliciesForResource } = usePolicies(); + const { + assignedPolicies, + resourceExists, + policies: allPolicies, + } = useNetworksContext(); + + const { policies: existingPolicies } = useMemo( + () => assignedPolicies(resource, groups), + [assignedPolicies, resource, groups], + ); + + const allResourcePolicies = useMemo(() => { + return [...(existingPolicies || []), ...policies]; + }, [existingPolicies, policies]); + + const groupPolicyCount = useMemo(() => { + if (!groups.length || !allPolicies) return 0; + const groupIds = new Set(groups.map((g) => g.id)); + return allPolicies.filter((policy) => { + const rule = policy.rules?.[0]; + if (!rule || rule.destinationResource) return false; + const destinations = rule.destinations as + | (Group | string)[] + | undefined; + return destinations?.some((d) => { + const id = typeof d === "string" ? d : d.id; + return !!id && groupIds.has(id); + }); + }).length; + }, [groups, allPolicies]); + + const isAddressValid = address.length > 0 && addressError === ""; + + const nameError = useMemo(() => { + if (name === "") return ""; + if (resourceExists(name, resource?.id)) + return "A resource with this name already exists. Please use another name."; + return ""; + }, [name, resourceExists, resource?.id]); + + const confirmMissingPolicies = async () => { + if (allResourcePolicies.length > 0) return true; + return confirm({ + title: "No Access Control Policies Configured", + description: + "Without access control policies, this resource will not be accessible by any peers. You can also create policies later. Are you sure you want to continue?", + type: "warning", + confirmText: resource ? "Save Changes" : "Add Resource", + cancelText: "Cancel", + maxWidthClass: "max-w-lg", + }); + }; const createResource = async () => { + if (!(await confirmMissingPolicies())) return; const savedGroups = await saveGroups(); + const promise = create({ + name, + description, + address, + groups: savedGroups ? savedGroups.map((g) => g.id) : undefined, + enabled, + }).then(async (r) => { + await createPoliciesForResource(policies, r); + onCreated?.(r); + }); + notify({ title: "Resource Created", description: `The resource "${name}" has been created successfully.`, loadingMessage: "Creating resource...", - promise: create({ - name, - description, - address, - groups: savedGroups ? savedGroups.map((g) => g.id) : undefined, - enabled, - }).then((r) => { - onCreated?.(r); - }), + promise, }); + + return promise; }; const updateResource = async () => { + if (!(await confirmMissingPolicies())) return; const savedGroups = await saveGroups(); + const promise = update({ + name, + description, + address, + groups: savedGroups ? savedGroups.map((g) => g.id) : undefined, + enabled, + }).then(async (r) => { + await createPoliciesForResource(policies, r); + onUpdated?.(r); + }); notify({ title: "Resource Updated", - description: `The resource "${name}" has been updated successfully.`, + description: `Resource "${name}" has been updated successfully.`, loadingMessage: "Updating resource...", - promise: update({ - name, - description, - address, - groups: savedGroups ? savedGroups.map((g) => g.id) : undefined, - enabled, - }).then((r) => { - onUpdated?.(r); - }), + promise, }); }; - // TODO: Address validation is missing for proper handling of submit button const canCreate = useMemo(() => { - return name.length > 0 && address.length > 0; - }, [name, address, groups]); + return name.length > 0 && isAddressValid && nameError === ""; + }, [name, isAddressValid, nameError]); return ( - + } title={resource ? "Edit Resource" : "Add Resource"} @@ -143,55 +231,168 @@ export function ResourceModalContent({ color={"yellow"} /> - - -
-
- - Provide a name for your resource - setName(e.target.value)} - /> -
-
- - - Write a short description to add more context to this resource. - - setDescription(e.target.value)} - /> -
+ setTab(v)} value={tab}> + + + + Resource + + + + Access Control + + + + Name & Description + + - + +
+ + Enter a single{" "} + + IP Address + + ,{" "} + + CIDR Block + {" "} + or{" "} + + Domain Name + + + } + /> -
- - - Add this resource to groups and use them as destinations when - creating policies - - -
-
- - - Enable Resource - - } - helpText={"Use this switch to enable or disable the resource."} +
+ + + Organize this resource into a group (e.g., Databases, Web + Servers) and reference the group in access policies to keep + rules reusable and easy to maintain. + + + {groupPolicyCount > 0 && ( + + Your selected resource groups are used in{" "} + + {groupPolicyCount} Access Control{" "} + {groupPolicyCount === 1 ? "Policy" : "Policies"} + + . This resource will inherit access from{" "} + {groupPolicyCount === 1 ? "this policy" : "these policies"}. + {isAddressValid || resource ? ( + <> + {" "} + Please review them in the{" "} + setTab("access-control")} + variant={"dashed"} + > + Access Control + {" "} + tab. + + ) : ( + " Please review them in the Access Control tab." + )} + + )} +
+ + + Enable Resource + + } + helpText={"Use this switch to enable or disable the resource."} + /> +
+ + + + 0} /> -
-
+ + + +
+
+ + + Set an easily identifiable name for your resource + + setName(e.target.value)} + /> +
+
+ + + Write a short description to add more context to this resource. + + setDescription(e.target.value)} + /> +
+
+
+
@@ -207,25 +408,78 @@ export function ResourceModalContent({
- - - - - + {!resource ? ( + <> + {tab === "resource" && ( + <> + + + + + + )} + + {tab === "access-control" && ( + <> + + + + )} + + {tab === "general" && ( + <> + + + + )} + + ) : ( + <> + + + + + + )}
diff --git a/src/modules/networks/resources/ResourceGroupCell.tsx b/src/modules/networks/resources/ResourceGroupCell.tsx index 9b6108c2..b6d6bb94 100644 --- a/src/modules/networks/resources/ResourceGroupCell.tsx +++ b/src/modules/networks/resources/ResourceGroupCell.tsx @@ -1,6 +1,8 @@ +import Badge from "@components/Badge"; import MultipleGroups, { TransparentEditIconButton, } from "@components/ui/MultipleGroups"; +import { IconCirclePlus } from "@tabler/icons-react"; import * as React from "react"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Group } from "@/interfaces/Group"; @@ -15,6 +17,9 @@ export const ResourceGroupCell = ({ resource }: Props) => { const { network, openResourceGroupModal } = useNetworksContext(); + const groups = resource?.groups as Group[] | undefined; + const hasGroups = groups && groups.length > 0; + return ( ); }; diff --git a/src/modules/networks/resources/ResourceGroupModal.tsx b/src/modules/networks/resources/ResourceGroupModal.tsx index 3ef80571..62e666d5 100644 --- a/src/modules/networks/resources/ResourceGroupModal.tsx +++ b/src/modules/networks/resources/ResourceGroupModal.tsx @@ -8,9 +8,7 @@ import { import ModalHeader from "@components/modal/ModalHeader"; import { notify } from "@components/Notification"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; -import Separator from "@components/Separator"; import { useApiCall } from "@utils/api"; -import { FolderGit2 } from "lucide-react"; import * as React from "react"; import { Network, NetworkResource } from "@/interfaces/Network"; import useGroupHelper from "@/modules/groups/useGroupHelper"; @@ -78,21 +76,22 @@ const ResourceGroupModalContent = ({ }; return ( - + } - title={"Assigned Groups"} + title={"Resource Groups"} description={ - "Add this resource to groups and use them as destinations when creating policies" + "Organize this resource into a group (e.g., Databases, Web Servers) and reference the group in access policies to keep rules reusable and easy to maintain." } - color={"blue"} /> - - -
+
- +
diff --git a/src/modules/networks/resources/ResourceNameCell.tsx b/src/modules/networks/resources/ResourceNameCell.tsx index ec61b51b..01490fcb 100644 --- a/src/modules/networks/resources/ResourceNameCell.tsx +++ b/src/modules/networks/resources/ResourceNameCell.tsx @@ -46,7 +46,7 @@ export default function ResourceNameCell({ resource }: Readonly) { />
diff --git a/src/modules/networks/resources/ResourcePolicyCell.tsx b/src/modules/networks/resources/ResourcePolicyCell.tsx index 4c32adb1..b0e5e2d4 100644 --- a/src/modules/networks/resources/ResourcePolicyCell.tsx +++ b/src/modules/networks/resources/ResourcePolicyCell.tsx @@ -1,52 +1,37 @@ import Badge from "@components/Badge"; import Button from "@components/Button"; import FullTooltip from "@components/FullTooltip"; -import useFetchApi from "@utils/api"; -import { orderBy } from "lodash"; -import { PlusCircle, ShieldIcon, SquarePenIcon } from "lucide-react"; +import { Settings, ShieldIcon, ShieldOff, SquarePenIcon } from "lucide-react"; import * as React from "react"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import Skeleton from "react-loading-skeleton"; import CircleIcon from "@/assets/icons/CircleIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; -import { Group } from "@/interfaces/Group"; import { NetworkResource } from "@/interfaces/Network"; import { Policy } from "@/interfaces/Policy"; import { useNetworksContext } from "@/modules/networks/NetworkProvider"; +import { cn } from "@utils/helpers"; type Props = { resource?: NetworkResource; }; export const ResourcePolicyCell = ({ resource }: Props) => { const { permission } = usePermissions(); - const { openPolicyModal, network, openEditPolicyModal } = - useNetworksContext(); - const { data: policies, isLoading } = useFetchApi("/policies"); + const { + openResourceModal, + network, + openEditPolicyModal, + assignedPolicies, + confirmMultiResourceAction, + } = useNetworksContext(); + const { + policies: resourcePolicies, + enabledPolicies, + isLoading, + policyCount, + } = assignedPolicies(resource); const [tooltipOpen, setTooltipOpen] = useState(false); - const assignedPolicies = useMemo(() => { - const resourceGroups = resource?.groups as Group[]; - return orderBy( - policies?.filter((policy) => { - const destinationResource = policy.rules - ?.map((rule) => rule?.destinationResource?.id === resource?.id) - .some((id) => id); - if (destinationResource) return true; - const destinationPolicyGroups = policy.rules - ?.map((rule) => rule?.destinations) - .flat() as Group[]; - const policyGroups = [...destinationPolicyGroups]; - return resourceGroups?.some((resourceGroup) => - policyGroups.some( - (policyGroup) => policyGroup?.id === resourceGroup.id, - ), - ); - }), - "enabled", - "desc", - ); - }, [policies, resource]); - if (isLoading) { return (
@@ -55,13 +40,16 @@ export const ResourcePolicyCell = ({ resource }: Props) => { ); } - const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled); - - const policyCount = assignedPolicies?.length || 0; - return ( network && (
+ {policyCount === 0 && ( + + + None + + )} + {policyCount > 0 && ( { className={"border-nb-gray-800"} content={
- {assignedPolicies?.map((policy: Policy) => { + {resourcePolicies?.map((policy: Policy) => { const rule = policy?.rules?.[0]; - if (!rule) return; + if (!rule) return null; return (
) diff --git a/src/modules/networks/resources/ResourceSingleAddressInput.tsx b/src/modules/networks/resources/ResourceSingleAddressInput.tsx index 3e29fdf1..536f0d88 100644 --- a/src/modules/networks/resources/ResourceSingleAddressInput.tsx +++ b/src/modules/networks/resources/ResourceSingleAddressInput.tsx @@ -13,8 +13,9 @@ type Props = { label?: string; className?: string; onError?: (error: string) => void; - description?: string; + description?: React.ReactNode; placeholder?: string; + autoFocus?: boolean; }; export const ResourceSingleAddressInput = ({ value, @@ -24,6 +25,7 @@ export const ResourceSingleAddressInput = ({ onError, description = "Enter a single IP address, CIDR block or domain name", placeholder = "Address (IP, CIDR or Domain)", + autoFocus, }: Props) => { const hasChars = useMemo(() => { return !!value.match(/[a-z*]/i); @@ -71,6 +73,7 @@ export const ResourceSingleAddressInput = ({ {description} [] = [ return groups.map((group) => group.name).join(", "); }, header: ({ column }) => { - return Groups; + return Resource Groups; }, cell: ({ row }) => { return ; @@ -121,7 +121,12 @@ export default function ResourcesTable({ const params = useSearchParams(); const resourceId = params.get("resource") ?? undefined; - const [sorting, setSorting] = useState([]); + const [sorting, setSorting] = useState([ + { + id: "name", + desc: false, + }, + ]); const { openResourceModal, network } = useNetworksContext(); const router = useRouter(); diff --git a/src/modules/networks/table/NetworksTable.tsx b/src/modules/networks/table/NetworksTable.tsx index 12e3fd0b..dc09b498 100644 --- a/src/modules/networks/table/NetworksTable.tsx +++ b/src/modules/networks/table/NetworksTable.tsx @@ -16,6 +16,7 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLocalStorage } from "@/hooks/useLocalStorage"; import { Network } from "@/interfaces/Network"; +import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider"; import { NetworkProvider, useNetworksContext, @@ -99,82 +100,84 @@ export default function NetworksTable({ return ( <> - - setSearchModal(true)} - getStartedCard={ - - } - color={"gray"} - size={"large"} - /> - } - title={"Create New Network"} - description={ - "It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network." - } - button={ -
+ + + setSearchModal(true)} + getStartedCard={ + + } + color={"gray"} + size={"large"} + /> + } + title={"Create New Network"} + description={ + "It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network." + } + button={ +
+ +
+ } + learnMore={ + <> + Learn more about + + Networks + + + + } + /> + } + rightSide={() => + data && + data.length > 0 && ( +
- } - learnMore={ - <> - Learn more about - - Networks - - - - } - /> - } - rightSide={() => - data && - data.length > 0 && ( -
- -
- ) - } - > - {(table) => ( - <> - - { - mutate("/networks").then(); - }} - /> - - )} -
-
+ ) + } + > + {(table) => ( + <> + + { + mutate("/networks").then(); + }} + /> + + )} + + +
); } diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 693a3abc..b7ef4b27 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -360,7 +360,7 @@ export default function ReverseProxyModal({ - Settings + Advanced Settings diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx index 61fa0904..36e0d4b2 100644 --- a/src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx +++ b/src/modules/reverse-proxy/events/ReverseProxyEventsTable.tsx @@ -11,12 +11,10 @@ import GetStartedTest from "@components/ui/GetStartedTest"; import type { ColumnDef, SortingState } from "@tanstack/react-table"; import { ExternalLinkIcon } from "lucide-react"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; -import { usePathname } from "next/navigation"; import dayjs from "dayjs"; -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { DateRange } from "react-day-picker"; import { DatePickerWithRange } from "@components/DatePickerWithRange"; -import { useLocalStorage } from "@/hooks/useLocalStorage"; import { useServerPagination } from "@/contexts/ServerPaginationProvider"; import { REVERSE_PROXY_EVENTS_DOCS_LINK, @@ -38,7 +36,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ { id: "timestamp", header: ({ column }) => ( - Time + + Time + ), cell: ({ row }) => ( @@ -52,7 +52,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ accessorFn: (row) => `${row.source_ip} ${row.city_name || ""} ${row.country_code || ""}`, header: ({ column }) => ( - Location / IP + + Location / IP + ), cell: ({ row }) => ( @@ -62,7 +64,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ id: "method", accessorKey: "method", header: ({ column }) => ( - Method + + Method + ), cell: ({ row }) => , filterFn: "arrIncludesSomeExact", @@ -71,7 +75,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ id: "url", accessorFn: (row) => `${row.host} ${row.path}`, header: ({ column }) => ( - URL + + URL + ), cell: ({ row }) => , }, @@ -79,7 +85,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ id: "status", accessorKey: "status_code", header: ({ column }) => ( - Status + + Status + ), cell: ({ row }) => , size: 80, @@ -94,7 +102,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ id: "duration", accessorKey: "duration_ms", header: ({ column }) => ( - Duration + + Duration + ), cell: ({ row }) => , }, @@ -102,7 +112,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ id: "auth_method", accessorKey: "auth_method_used", header: ({ column }) => ( - Auth Method + + Auth Method + ), cell: ({ row }) => ( @@ -112,7 +124,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ id: "reason", accessorKey: "reason", header: ({ column }) => ( - Reason + + Reason + ), cell: ({ row }) => , }, @@ -120,7 +134,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef[] = [ id: "user", accessorFn: (row) => row.user_id || "", header: ({ column }) => ( - User + + User + ), cell: ({ row }) => , }, @@ -136,8 +152,6 @@ type Props = { export default function ReverseProxyEventsTable({ headingTarget, }: Readonly) { - const path = usePathname(); - const { data: events, isLoading, @@ -174,15 +188,12 @@ export default function ReverseProxyEventsTable({ [setFilter], ); - const [sorting, setSorting] = useLocalStorage( - "netbird-table-sort" + path, - [ - { - id: "timestamp", - desc: true, - }, - ], - ); + const [sorting, setSorting] = useState([ + { + id: "timestamp", + desc: true, + }, + ]); return ( ) { - const status = meta?.status; - const certificateIssued = !!meta?.certificate_issued_at; + const dataRef = useRef(undefined); - const isSettingUp = - enabled && - status !== undefined && - status !== ReverseProxyStatus.ACTIVE && - !certificateIssued; + const isActive = + meta?.status === ReverseProxyStatus.ACTIVE || + dataRef.current?.meta?.status === ReverseProxyStatus.ACTIVE; + + const certificateIssued = + !!meta?.certificate_issued_at || + !!dataRef.current?.meta?.certificate_issued_at; + + const shouldPoll = !!enabled && !(isActive && certificateIssued); const { data } = useFetchApi( `/reverse-proxies/services/${serviceId}`, true, false, - isSettingUp, + shouldPoll, { refreshInterval: POLL_INTERVAL_MS }, ); - const currentStatus = data?.meta?.status ?? status; - - const currentCertificateIssued = useMemo(() => { - if (data && data?.meta) return !!data?.meta?.certificate_issued_at; - return certificateIssued; - }, [data]); + dataRef.current = data; - if ( - !enabled || - (currentStatus === ReverseProxyStatus.ACTIVE && currentCertificateIssued) - ) { + if (!enabled || (isActive && certificateIssued)) { return null; } - if (!currentCertificateIssued) { + if (!certificateIssued) { return (
diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders.tsx new file mode 100644 index 00000000..b697b31b --- /dev/null +++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders.tsx @@ -0,0 +1,185 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { MinusCircleIcon, PlusIcon } from "lucide-react"; +import { useCallback, useState } from "react"; + +const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/; +const BLOCKED_HEADERS = new Set([ + "host", + "connection", + "transfer-encoding", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "upgrade", +]); + +type HeaderEntry = { id: number; name: string; value: string }; + +function recordToHeaderEntries( + record: Record | undefined, + nextId: () => number, +): HeaderEntry[] { + if (!record) return []; + return Object.entries(record).map(([name, value]) => ({ + id: nextId(), + name, + value, + })); +} + +export function headerEntriesToRecord( + entries: HeaderEntry[], +): Record | undefined { + if (entries.length === 0) return undefined; + const record: Record = {}; + for (const entry of entries) { + if (entry.name) record[entry.name] = entry.value; + } + return Object.keys(record).length > 0 ? record : undefined; +} + +function validateHeaderName( + name: string, + allNames: string[], +): string | undefined { + if (!name) return undefined; + if (!HEADER_NAME_RE.test(name)) + return "Invalid characters in header name. Please use another one."; + if (BLOCKED_HEADERS.has(name.toLowerCase())) + return `"${name}" is a reserved header. Please use another one.`; + const dupeCount = allNames.filter( + (n) => n.toLowerCase() === name.toLowerCase(), + ).length; + if (dupeCount > 1) return "Duplicate header name. Please use another one."; + return undefined; +} + +function validateHeaderValue(value: string): string | undefined { + if (value.includes("\r") || value.includes("\n")) + return "Value must not contain line breaks"; + return undefined; +} + +export function useCustomHeaders(initialHeaders?: Record) { + const [nextId] = useState(() => { + let id = 0; + return () => ++id; + }); + + const [headerEntries, setHeaderEntries] = useState(() => + recordToHeaderEntries(initialHeaders, nextId), + ); + + const addHeader = useCallback(() => { + setHeaderEntries((prev) => [ + ...prev, + { id: nextId(), name: "", value: "" }, + ]); + }, [nextId]); + + const removeHeader = useCallback((id: number) => { + setHeaderEntries((prev) => prev.filter((h) => h.id !== id)); + }, []); + + const updateHeaderEntry = useCallback( + (id: number, field: "name" | "value", fieldValue: string) => { + setHeaderEntries((prev) => + prev.map((h) => (h.id === id ? { ...h, [field]: fieldValue } : h)), + ); + }, + [], + ); + + const allHeaderNames = headerEntries.map((h) => h.name); + const headerErrors = headerEntries.map((entry) => ({ + name: validateHeaderName(entry.name, allHeaderNames), + value: validateHeaderValue(entry.value), + })); + + const hasHeaderErrors = headerErrors.some((e) => e.name || e.value); + + return { + headerEntries, + setHeaderEntries, + addHeader, + removeHeader, + updateHeaderEntry, + headerErrors, + hasHeaderErrors, + }; +} + +export type CustomHeadersProps = Pick< + ReturnType, + | "headerEntries" + | "addHeader" + | "removeHeader" + | "updateHeaderEntry" + | "headerErrors" +>; + +export default function ReverseProxyTargetCustomHeaders({ + headerEntries, + addHeader, + removeHeader, + updateHeaderEntry, + headerErrors, +}: CustomHeadersProps) { + return ( +
+ + + Add additional headers to include when forwarding requests. +
+ Hop-by-hop headers like Host or Connection are not allowed. +
+ {headerEntries.length > 0 && ( +
+ {headerEntries.map((entry, index) => ( +
+ + updateHeaderEntry(entry.id, "name", e.target.value) + } + maxWidthClass="flex-1" + error={headerErrors[index]?.name} + errorTooltip + /> + + updateHeaderEntry(entry.id, "value", e.target.value) + } + maxWidthClass="flex-1" + error={headerErrors[index]?.value} + errorTooltip + /> + +
+ ))} +
+ )} + +
+ ); +} diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx index e2ec3600..5df5a216 100644 --- a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx +++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx @@ -1,13 +1,7 @@ "use client"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@components/Accordion"; import Button from "@components/Button"; -import { Checkbox } from "@components/Checkbox"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; import HelpText from "@components/HelpText"; import { Input } from "@components/Input"; import { Label } from "@components/Label"; @@ -15,14 +9,17 @@ import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; import ModalHeader from "@components/modal/ModalHeader"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; import { SelectDropdown } from "@components/select/SelectDropdown"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; import useFetchApi from "@utils/api"; import { AlertTriangle, + ClockFadingIcon, ExternalLinkIcon, - HelpCircle, PlusCircle, Server, Settings, + ShieldXIcon, + Text, } from "lucide-react"; import { Callout } from "@components/Callout"; import cidr from "ip-cidr"; @@ -35,18 +32,19 @@ import { ReverseProxyTarget, ReverseProxyTargetProtocol, ReverseProxyTargetType, + ServiceTargetOptionsPathRewrite, } from "@/interfaces/ReverseProxy"; import { defaultPortForProtocol, isResourceTargetType, } from "@/contexts/ReverseProxiesProvider"; -import Separator from "@components/Separator"; import { cn } from "@utils/helpers"; import { HelpTooltip } from "@components/HelpTooltip"; import InlineLink, { InlineButtonLink } from "@components/InlineLink"; import SetupModal from "@/modules/setup-netbird-modal/SetupModal"; -import FullTooltip from "@components/FullTooltip"; import Paragraph from "@components/Paragraph"; +import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders"; +import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions"; /** Get initial host value based on target, resource, or peer */ function getInitialHost( @@ -93,6 +91,8 @@ export default function ReverseProxyTargetModal({ ); const { data: peers } = useFetchApi("/peers"); + const [tab, setTab] = useState("details"); + const [targetType, setTargetType] = useState( currentTarget?.target_type ?? (initialResource @@ -121,9 +121,9 @@ export default function ReverseProxyTargetModal({ currentTarget?.port ?? 0, ); const [targetPath, setTargetPath] = useState(currentTarget?.path ?? ""); - const [accessLocal, setAccessLocal] = useState( - currentTarget?.access_local ?? false, - ); + const [accessLocal] = useState(currentTarget?.access_local ?? false); + const [options, setOption, { getTargetOptions, headers, errors }] = + useReverseProxyTargetOptions(currentTarget?.options); const portInputRef = useRef(null); const [installModal, setInstallModal] = useState(false); @@ -232,7 +232,8 @@ export default function ReverseProxyTargetModal({ const handleSave = () => { const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType; - const isResource = isResourceTargetType(resolvedType) || !!initialResource; + const resolvedIsResource = + isResourceTargetType(resolvedType) || !!initialResource; const targetData: ReverseProxyTarget = { target_type: resolvedType, target_id: @@ -245,19 +246,17 @@ export default function ReverseProxyTargetModal({ port: targetPort, path: targetPath || undefined, enabled: currentTarget?.enabled ?? true, - access_local: isResource ? accessLocal : undefined, + access_local: resolvedIsResource ? accessLocal : undefined, + options: getTargetOptions(), }; onSave(targetData); onOpenChange(false); }; - const showAdvancedSettings = false; - // const showAdvancedSettings = !!hasTarget && (isResourceTargetType(targetType) || !!initialResource); - return ( <> - + } title={currentTarget ? "Edit Target" : "Add Target"} @@ -265,308 +264,397 @@ export default function ReverseProxyTargetModal({ color="netbird" /> - + + + + + Details + + + + Advanced Settings + + -
- {!initialResource && !initialPeer && ( -
-
- } - > - Peer - {" "} - or{" "} - - A{" "} - - resource - {" "} - is a destination (IP, subnet, or domain) that - can't run NetBird directly. Resources are part - of a network and are reached through a routing peer - that forwards traffic to them. - - If you don't have resources yet, go to{" "} - - Networks - {" "} - to create some. - -
- } - > - Resource - - - )} - + +
+ {!initialResource && !initialPeer && ( +
+ - - {initialNetwork - ? "Select the resource from your network you want to expose." - : "Select the peer where your service is running or select a resource to expose it."} - - {}} - placeholder={ - initialNetwork - ? "Select a resource..." - : "Select a peer or resource..." - } - showPeers={!initialNetwork} - showResources={true} - showRoutes={false} - hideAllGroup={true} - hideGroupsTab={true} - resourceIds={ - initialNetwork ? initialNetwork.resources ?? [] : undefined - } - tabOrder={ - initialNetwork ? ["resources"] : ["peers", "resources"] - } - closeOnSelect={true} - max={1} - resource={ - isResourceTargetType(targetType) && targetResourceId - ? { id: targetResourceId, type: "host" } - : targetType === ReverseProxyTargetType.PEER && - targetPeerId - ? { id: targetPeerId, type: "peer" } - : undefined - } - onResourceChange={(res) => { - if (res) { - if (res.type === "peer") { - setTargetType(ReverseProxyTargetType.PEER); - setTargetPeerId(res.id); - setTargetResourceId(undefined); - const peer = peers?.find((p) => p.id === res.id); - setTargetHost(peer?.ip || "localhost"); - } else { - const selectedResource = resources?.find( - (r) => r.id === res.id, - ); - setTargetType( - (selectedResource?.type as ReverseProxyTargetType) ?? - ReverseProxyTargetType.HOST, - ); - setTargetResourceId(res.id); - setTargetPeerId(undefined); - const address = selectedResource?.address || ""; - // If CIDR range, pre-fill with base IP - if (address.includes("/")) { - setTargetHost(address.split("/")[0]); + + {initialNetwork + ? "Select the resource from your network you want to expose." + : "Select the peer where your service is running or select a resource to expose it."} + + {}} + placeholder={ + initialNetwork + ? "Select a resource..." + : "Select a peer or resource..." + } + showPeers={!initialNetwork} + showResources={true} + showRoutes={false} + hideAllGroup={true} + hideGroupsTab={true} + resourceIds={ + initialNetwork + ? initialNetwork.resources ?? [] + : undefined + } + tabOrder={ + initialNetwork ? ["resources"] : ["peers", "resources"] + } + closeOnSelect={true} + max={1} + resource={ + isResourceTargetType(targetType) && targetResourceId + ? { id: targetResourceId, type: "host" } + : targetType === ReverseProxyTargetType.PEER && + targetPeerId + ? { id: targetPeerId, type: "peer" } + : undefined + } + onResourceChange={(res) => { + if (res) { + if (res.type === "peer") { + setTargetType(ReverseProxyTargetType.PEER); + setTargetPeerId(res.id); + setTargetResourceId(undefined); + const peer = peers?.find((p) => p.id === res.id); + setTargetHost(peer?.ip || "localhost"); + } else { + const selectedResource = resources?.find( + (r) => r.id === res.id, + ); + setTargetType( + (selectedResource?.type as ReverseProxyTargetType) ?? + ReverseProxyTargetType.HOST, + ); + setTargetResourceId(res.id); + setTargetPeerId(undefined); + const address = selectedResource?.address || ""; + // If CIDR range, pre-fill with base IP + if (address.includes("/")) { + setTargetHost(address.split("/")[0]); + } else { + setTargetHost(address); + } + } + setTimeout(() => portInputRef.current?.focus(), 0); } else { - setTargetHost(address); + setTargetPeerId(undefined); + setTargetResourceId(undefined); + setTargetHost(""); } - } - setTimeout(() => portInputRef.current?.focus(), 0); - } else { - setTargetPeerId(undefined); - setTargetResourceId(undefined); - setTargetHost(""); - } - }} - /> -
- )} - -
- - - Specify an optional path from where requests are routed to your - service. - -
-
- {domain || "domain.example.com"} -
- { - let value = e.target.value; - if (value && !value.startsWith("/")) { - value = "/" + value; - } - setTargetPath(value); - }} - /> -
- {isPathDuplicate && hasTarget && ( - - } - > - Please use a different location. This location is already used - by another target and cannot be added. - - )} -
+
+ )} -
-
- - {cidrInfo && ( - - Enter an IP address within {currentResourceAddress} +
+ + + Specify an optional path from where requests are routed to + your service. - )} -
-
- - setTargetProtocol(v as ReverseProxyTargetProtocol) - } - options={[ - { - value: ReverseProxyTargetProtocol.HTTP, - label: "http://", - }, - { - value: ReverseProxyTargetProtocol.HTTPS, - label: "https://", - }, - ]} - className="!rounded-r-none !border-r-0" - disabled={!hasTarget} - /> -
-
+
+
+ {domain || "domain.example.com"} +
{ - // Only allow valid IP characters for CIDR ranges - const value = isHostEditable - ? e.target.value.replace(/[^0-9.]/g, "") - : e.target.value; - setTargetHost(value); + let value = e.target.value; + if (value && !value.startsWith("/")) { + value = "/" + value; + } + setTargetPath(value); + if (!value || value === "/") { + setOption("path_rewrite", undefined); + } }} - placeholder="e.g., 192.168.0.10" - className="!rounded-l-none" - disabled={!hasTarget} - readOnly={hasTarget && !isHostEditable ? true : undefined} - autoFocus={!!initialResource && isHostEditable} />
+ {isPathDuplicate && hasTarget && ( + + } + > + This location is already used by another target and cannot + be added.
Please use a different location. +
+ )} + {targetPath && + targetPath !== "/" && + hasTarget && + !isPathDuplicate && ( + + setOption( + "path_rewrite", + v + ? ("preserve" as ServiceTargetOptionsPathRewrite) + : undefined, + ) + } + className={"mt-3.5"} + label={ + <> + Preserve Full Path + +
+ When disabled, a request to e.g.,{" "} + + {targetPath}/users + {" "} + is forwarded as{" "} + + /users + + . +
+
+ When enabled, a request to e.g.,{" "} + + {targetPath}/users + {" "} + is forwarded as{" "} + + {targetPath}/users + + . +
+
+ } + /> + + } + helpText={ +
+ Keep the original full request path when forwarding.{" "} +
+ When disabled the matched prefix path is stripped. +
+ } + /> + )} +
+ +
+
+
+ + {cidrInfo && ( + + Enter an IP address within {currentResourceAddress} + + )} +
+
+ { + const proto = v as ReverseProxyTargetProtocol; + setTargetProtocol(proto); + if (proto !== ReverseProxyTargetProtocol.HTTPS) { + setOption("skip_tls_verify", undefined); + } + }} + options={[ + { + value: ReverseProxyTargetProtocol.HTTP, + label: "http://", + }, + { + value: ReverseProxyTargetProtocol.HTTPS, + label: "https://", + }, + ]} + className="!rounded-r-none !border-r-0" + disabled={!hasTarget} + /> +
+
+ { + // Only allow valid IP characters for CIDR ranges + const value = isHostEditable + ? e.target.value.replace(/[^0-9.]/g, "") + : e.target.value; + setTargetHost(value); + }} + placeholder="e.g., 192.168.0.10" + className="!rounded-l-none" + disabled={!hasTarget} + readOnly={ + hasTarget && !isHostEditable ? true : undefined + } + autoFocus={!!initialResource && isHostEditable} + /> +
+
+
+
+ + {cidrInfo && ( +   + )} +
+ + setTargetPort(parseInt(e.target.value) || 0) + } + placeholder={String( + defaultPortForProtocol(targetProtocol), + )} + min={0} + max={65535} + disabled={!hasTarget} + autoFocus={!!initialResource && !isHostEditable} + /> +
+
+
+ {targetProtocol === ReverseProxyTargetProtocol.HTTPS && + hasTarget && ( + + setOption("skip_tls_verify", v || undefined) + } + label={ + <> + + Skip TLS Verification + + } + helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate." + /> + )}
-
-
- } - > - - - - {cidrInfo &&  } -
+ + + +
+
+
+ + + Max time to wait for a response as duration string (max + 5m).
Leave this field empty for no timeout. +
+
} + placeholder="e.g. 10s, 30s, 1m" + value={options.request_timeout ?? ""} onChange={(e) => - setTargetPort(parseInt(e.target.value) || 0) + setOption("request_timeout", e.target.value || undefined) } - placeholder={String(defaultPortForProtocol(targetProtocol))} - min={0} - max={65535} - disabled={!hasTarget} - autoFocus={!!initialResource && !isHostEditable} + maxWidthClass="w-[180px]" + errorTooltip={true} + error={errors.timeout} />
-
-
- {showAdvancedSettings && ( - - - -
- - Advanced Settings -
-
- -
- -
-
-
-
- )} -
+ +
+
+
@@ -582,23 +670,61 @@ export default function ReverseProxyTargetModal({
- - + {currentTarget ? ( + <> + + + + ) : ( + <> + {tab === "details" && ( + <> + + + + )} + {tab === "options" && ( + <> + + + + )} + + )}
diff --git a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts new file mode 100644 index 00000000..79f79712 --- /dev/null +++ b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts @@ -0,0 +1,101 @@ +import { useCallback, useState } from "react"; +import { ServiceTargetOptions } from "@/interfaces/ReverseProxy"; +import { + headerEntriesToRecord, + useCustomHeaders, +} from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders"; + +// Go time.ParseDuration format: one or more {number}{unit} pairs +const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/; +const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m + +function parseDurationMs(duration: string): number { + const units: Record = { + ns: 1e-6, + us: 1e-3, + µs: 1e-3, + ms: 1, + s: 1000, + m: 60_000, + h: 3_600_000, + }; + let total = 0; + for (const [, val, , unit] of duration.matchAll( + /(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g, + )) { + total += parseFloat(val) * units[unit]; + } + return total; +} + +function validateTimeout(timeout: string): string | undefined { + if (!timeout) return undefined; + if (!DURATION_RE.test(timeout)) + return 'Invalid duration, use e.g., "10s", "30s", "1m"'; + if (parseDurationMs(timeout) > MAX_TIMEOUT_MS) + return "Timeout cannot exceed the maximum of 5m."; + return undefined; +} + +export function useReverseProxyTargetOptions( + initialOptions?: ServiceTargetOptions, +) { + const [targetOptions, setTargetOptions] = useState( + () => { + const { custom_headers: _, ...rest } = initialOptions ?? {}; + return rest; + }, + ); + + const { + headerEntries, + addHeader, + removeHeader, + updateHeaderEntry, + headerErrors, + hasHeaderErrors, + } = useCustomHeaders(initialOptions?.custom_headers); + + const updateOption = useCallback( + ( + key: K, + value: ServiceTargetOptions[K], + ) => { + setTargetOptions((prev) => ({ ...prev, [key]: value })); + }, + [], + ); + + const timeoutError = validateTimeout(targetOptions.request_timeout ?? ""); + const hasOptionsErrors = !!timeoutError || hasHeaderErrors; + + const getTargetOptions = useCallback((): ServiceTargetOptions | undefined => { + const customHeaders = headerEntriesToRecord(headerEntries); + const merged: ServiceTargetOptions = { + ...targetOptions, + custom_headers: customHeaders, + }; + const hasOptions = Object.values(merged).some((v) => v !== undefined); + return hasOptions ? merged : undefined; + }, [targetOptions, headerEntries]); + + return [ + targetOptions, + updateOption, + { + getTargetOptions, + headers: { + headerEntries, + addHeader, + removeHeader, + updateHeaderEntry, + headerErrors, + hasHeaderErrors, + }, + errors: { + timeout: timeoutError, + options: hasOptionsErrors, + }, + }, + ] as const; +}