diff --git a/src/contexts/CountryProvider.tsx b/src/contexts/CountryProvider.tsx index 1b6d42a4..bd3bb220 100644 --- a/src/contexts/CountryProvider.tsx +++ b/src/contexts/CountryProvider.tsx @@ -13,7 +13,7 @@ const CountryContext = React.createContext( countries: Country[] | undefined; isLoading: boolean; getRegionByPeer: (peer: Peer) => string; - getRegionText: (country_code: string, city_name: string) => string; + getRegionText: (country_code: string, city_name: string, subdivision_code?: string) => string; }, ); @@ -21,7 +21,7 @@ export default function CountryProvider({ children }: Props) { const { isRestricted } = usePermissions(); const getRegionByPeer = (peer: Peer) => "Unknown"; - const getRegionText = (country_code: string, city_name: string) => "Unknown"; + const getRegionText = (country_code: string, city_name: string, _subdivision_code?: string) => "Unknown"; return isRestricted ? ( { + (country_code: string, city_name: string, subdivision_code?: string) => { if (!countries) return "Unknown"; const country = countries.find((c) => c.country_code === country_code); if (!country) return "Unknown"; - if (!city_name) return country.country_name; - return `${country.country_name}, ${city_name}`; + const parts = [country.country_name]; + if (subdivision_code) parts.push(subdivision_code); + if (city_name) parts.push(city_name); + return parts.join(", "); }, [countries], ); diff --git a/src/interfaces/ReverseProxy.ts b/src/interfaces/ReverseProxy.ts index 50d9816c..bdaa91eb 100644 --- a/src/interfaces/ReverseProxy.ts +++ b/src/interfaces/ReverseProxy.ts @@ -18,6 +18,7 @@ export interface ReverseProxy { pass_host_header?: boolean; rewrite_redirects?: boolean; auth?: ReverseProxyAuth; + access_restrictions?: AccessRestrictions; meta?: ReverseProxyMeta; } @@ -77,6 +78,20 @@ export interface ReverseProxyAuth { link_auth?: { enabled: boolean; }; + header_auths?: HeaderAuthConfig[]; +} + +export interface HeaderAuthConfig { + enabled: boolean; + header: string; + value: string; +} + +export interface AccessRestrictions { + allowed_cidrs?: string[]; + blocked_cidrs?: string[]; + allowed_countries?: string[]; + blocked_countries?: string[]; } export interface ReverseProxyDomain { @@ -129,6 +144,7 @@ export interface ReverseProxyEvent { auth_method_used?: string; country_code?: string; city_name?: string; + subdivision_code?: string; bytes_upload: number; bytes_download: number; protocol?: EventProtocol; diff --git a/src/modules/reverse-proxy/AccessRestrictionsSection.tsx b/src/modules/reverse-proxy/AccessRestrictionsSection.tsx new file mode 100644 index 00000000..73a65acc --- /dev/null +++ b/src/modules/reverse-proxy/AccessRestrictionsSection.tsx @@ -0,0 +1,227 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { Label } from "@components/Label"; +import { CountrySelector } from "@components/ui/CountrySelector"; +import { cn } from "@utils/helpers"; +import cidr from "ip-cidr"; +import { Globe, MinusCircleIcon, PlusCircle, ShieldCheck } from "lucide-react"; +import React, { useState } from "react"; +import { AccessRestrictions } from "@/interfaces/ReverseProxy"; + +let nextRowId = 0; + +type Props = { + value: AccessRestrictions; + onChange: (value: AccessRestrictions) => void; +}; + +type CidrRow = { id: number; value: string }; + +type RowListProps = { + values: string[]; + onChange: (values: string[]) => void; +}; + +function CidrList({ values, onChange }: Readonly) { + const [rows, setRows] = useState(() => + values.map((v) => ({ id: nextRowId++, value: v })), + ); + const [errors, setErrors] = useState>({}); + + const addRow = () => { + const next = [...rows, { id: nextRowId++, value: "" }]; + setRows(next); + onChange(next.map((r) => r.value)); + }; + + const updateRow = (id: number, raw: string) => { + const next = rows.map((r) => (r.id === id ? { ...r, value: raw } : r)); + setRows(next); + onChange(next.map((r) => r.value)); + + if (raw && !cidr.isValidCIDR(raw)) { + setErrors((prev) => ({ ...prev, [id]: "Invalid CIDR format" })); + } else { + setErrors((prev) => { + const copy = { ...prev }; + delete copy[id]; + return copy; + }); + } + }; + + const removeRow = (id: number) => { + const next = rows.filter((r) => r.id !== id); + setRows(next); + onChange(next.map((r) => r.value)); + setErrors((prev) => { + const copy = { ...prev }; + delete copy[id]; + return copy; + }); + }; + + return ( +
+ {rows.map((row) => ( +
+
+ updateRow(row.id, e.target.value.trim())} + placeholder="e.g. 10.0.0.0/8" + className={cn( + "flex-1 h-[42px] px-3 text-sm font-mono bg-nb-gray-900/40 rounded-md outline-none", + "border placeholder:text-neutral-400/70", + errors[row.id] + ? "border-red-400" + : "border-nb-gray-700", + )} + /> + +
+ {errors[row.id] && ( +

{errors[row.id]}

+ )} +
+ ))} + +
+ ); +} + +type CountryRow = { id: number; value: string }; + +function CountryList({ values, onChange }: Readonly) { + const [rows, setRows] = useState(() => + values.map((v) => ({ id: nextRowId++, value: v })), + ); + + const addCountry = () => { + const next = [...rows, { id: nextRowId++, value: "" }]; + setRows(next); + onChange(next.map((r) => r.value)); + }; + + const updateCountry = (id: number, code: string) => { + if (rows.some((r) => r.value === code)) return; + const next = rows.map((r) => (r.id === id ? { ...r, value: code } : r)); + setRows(next); + onChange(next.map((r) => r.value)); + }; + + const removeCountry = (id: number) => { + const next = rows.filter((r) => r.id !== id); + setRows(next); + onChange(next.map((r) => r.value)); + }; + + return ( +
+ {rows.map((row) => ( +
+
+ updateCountry(row.id, v)} + /> +
+ +
+ ))} + +
+ ); +} + +export default function AccessRestrictionsSection({ + value, + onChange, +}: Readonly) { + const update = (patch: Partial) => { + onChange({ ...value, ...patch }); + }; + + return ( +
+
+ + + Only connections from these IP ranges will be allowed. All other + IPs will be blocked. Leave empty to allow all. + + update({ allowed_cidrs: v })} + /> +
+ +
+ + + Block connections from these IP ranges. Takes priority over the + allowlist. + + update({ blocked_cidrs: v })} + /> +
+ +
+ + + Only connections from these countries will be allowed. All other + countries will be blocked. Leave empty to allow all. + + update({ allowed_countries: v })} + /> +
+ +
+ + + Block connections from these countries. Takes priority over the + allowlist. + + update({ blocked_countries: v })} + /> +
+
+ ); +} diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 3fd3c361..1d59d434 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -1,11 +1,18 @@ "use client"; import Button from "@components/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; 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 { PeerGroupSelector } from "@components/PeerGroupSelector"; import SettingCard from "@components/SettingCard"; import { Modal, @@ -16,59 +23,69 @@ import { import Paragraph from "@components/Paragraph"; import ModalHeader from "@components/modal/ModalHeader"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; +import { ToggleSwitch } from "@components/ToggleSwitch"; import { + AlertTriangle, ArrowRight, + ArrowUpRight, Binary, - ClockFadingIcon, + Edit, ExternalLinkIcon, GlobeIcon, + KeyRound, LockKeyhole, - MapPinned, + MinusCircleIcon, + MoreVertical, + Network as NetworkIcon, PlusCircle, + PlusIcon, RectangleEllipsis, + Server, Settings, + ShieldCheck, + Text, + Timer, Users, } from "lucide-react"; +import { Callout } from "@components/Callout"; +import useFetchApi from "@utils/api"; +import cidr from "ip-cidr"; import { useRouter } from "next/navigation"; -import React, { useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; import { useDialog } from "@/contexts/DialogProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Network, NetworkResource } from "@/interfaces/Network"; import { Peer } from "@/interfaces/Peer"; import { - isL4Mode as isL4ServiceMode, + AccessRestrictions, REVERSE_PROXY_AUTHENTICATION_DOCS_LINK, REVERSE_PROXY_SERVICES_DOCS_LINK, REVERSE_PROXY_SETTINGS_DOCS_LINK, + HeaderAuthConfig, ReverseProxy, ReverseProxyAuth, ReverseProxyDomain, + ReverseProxyDomainType, ReverseProxyTarget, ReverseProxyTargetProtocol, ReverseProxyTargetType, ServiceMode, + isL4Mode as isL4ServiceMode, } from "@/interfaces/ReverseProxy"; -import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; -import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput"; -import { useReverseProxyDomain } from "./domain/useReverseProxyDomain"; +import { + isResourceTargetType, + useReverseProxies, +} from "@/contexts/ReverseProxiesProvider"; +import { CustomDomainSelector } from "./domain/CustomDomainSelector"; +import { cn } from "@utils/helpers"; +import AccessRestrictionsSection from "@/modules/reverse-proxy/AccessRestrictionsSection"; +import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal"; import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal"; import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal"; import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal"; -import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets"; -import ReverseProxyLayer4Content from "@/modules/reverse-proxy/ReverseProxyLayer4Content"; import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal"; -import { type Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector"; -import { useReverseProxyAddress } from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput"; -import { - validateTimeout, - validateSessionIdleTimeout, -} from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions"; import useGroupHelper from "@/modules/groups/useGroupHelper"; -import { - ReverseProxyServiceModeSelector, - SERVICE_MODES, -} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector"; type Props = { open: boolean; @@ -79,12 +96,70 @@ type Props = { initialSubdomain?: string; /** Pre-set a resource target - hides target selection in modal */ initialResource?: NetworkResource; + initialEndpointMode?: ServiceMode; initialPeer?: Peer; initialNetwork?: Network; initialTab?: string; onSuccess?: () => void; }; +// Helper to parse domain into subdomain and base domain. +// When availableDomains is provided, matches against them first (longest match wins) +// to avoid e.g. "netbird.io" matching when the actual domain is "eu.proxy.netbird.io". +function parseDomain( + fullDomain: string, + availableDomains?: ReverseProxyDomain[], +): { + subdomain: string; + baseDomain: string; + isCustom: boolean; +} { + // Try matching against actual available domains first (sorted longest-first for specificity) + if (availableDomains?.length) { + const sorted = [...availableDomains] + .filter((d) => d.domain) + .sort((a, b) => b.domain.length - a.domain.length); + for (const d of sorted) { + if (fullDomain.endsWith(`.${d.domain}`)) { + return { + subdomain: fullDomain.slice(0, -(d.domain.length + 1)), + baseDomain: d.domain, + isCustom: d.type === ReverseProxyDomainType.CUSTOM, + }; + } + } + } + + // Fallback to hardcoded known domains + const knownDomains = ["netbird.cloud", "netbird.io", "netbird.app"]; + + for (const known of knownDomains) { + if (fullDomain.endsWith(`.${known}`)) { + return { + subdomain: fullDomain.slice(0, -(known.length + 1)), + baseDomain: known, + isCustom: false, + }; + } + } + + // Custom domain - find the first dot to split + const firstDot = fullDomain.indexOf("."); + if (firstDot > 0) { + return { + subdomain: fullDomain.slice(0, firstDot), + baseDomain: fullDomain.slice(firstDot + 1), + isCustom: true, + }; + } + + return { + subdomain: fullDomain, + baseDomain: "netbird.cloud", + isCustom: false, + }; +} + export default function ReverseProxyModal({ open, onOpenChange, @@ -92,6 +167,7 @@ export default function ReverseProxyModal({ domains, initialSubdomain, initialResource, + initialEndpointMode, initialPeer, initialNetwork, initialTab, @@ -100,111 +176,189 @@ export default function ReverseProxyModal({ const router = useRouter(); const { permission } = usePermissions(); const { confirm } = useDialog(); - const { handleCreateOrUpdateProxy } = useReverseProxies(); - - const { - subdomain, - setSubdomain, - baseDomain, - setBaseDomain, - fullDomain, - domainAlreadyExists, - isClusterConnected, - } = useReverseProxyDomain({ reverseProxy, domains, initialSubdomain }); + const { reverseProxies, handleCreateOrUpdateProxy } = useReverseProxies(); + + // Check if the proxy's cluster exists in available free domains + const isClusterConnected = useMemo(() => { + if (!reverseProxy?.proxy_cluster) return false; + return domains?.some( + (d) => + d.type === ReverseProxyDomainType.FREE && + d.domain === reverseProxy.proxy_cluster, + ); + }, [reverseProxy?.proxy_cluster, domains]); const [tab, setTab] = useState(() => { if (initialTab && initialTab !== "") return initialTab; return "targets"; }); - const [serviceMode, setServiceMode] = useState( - reverseProxy?.mode ?? ServiceMode.HTTP, - ); + // Parse existing domain if editing + // All modes store subdomain.cluster as domain + const parsed = reverseProxy?.domain + ? parseDomain(reverseProxy.domain, domains) + : null; - const isL4Mode = isL4ServiceMode(serviceMode); + const [subdomain, setSubdomain] = useState(() => { + return parsed?.subdomain || + initialSubdomain + ?.toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") || + ""; + }); - // L4 target selection state (TLS/TCP/UDP) - target is in targets[0] - const [l4Target, setL4Target] = useState(() => { - const existing = isL4ServiceMode(reverseProxy?.mode) - ? reverseProxy?.targets?.[0] - : undefined; - if (existing) { - const isPeer = existing.target_type === ReverseProxyTargetType.PEER; - return { - type: existing.target_type, - peerId: isPeer ? existing.target_id : undefined, - resourceId: isPeer ? undefined : existing.target_id, - host: existing.host || "", - }; - } - if (initialResource) { - const addr = initialResource.address; - return { - type: - (initialResource.type as ReverseProxyTargetType) ?? - ReverseProxyTargetType.HOST, - resourceId: initialResource.id, - host: addr.includes("/") ? addr.split("/")[0] : addr, - }; - } - if (initialPeer) { - return { - type: ReverseProxyTargetType.PEER, - peerId: initialPeer.id, - host: initialPeer.ip, - }; + const [baseDomain, setBaseDomain] = useState(() => { + if (parsed?.baseDomain) return parsed.baseDomain; + const validatedDomains = domains?.filter((d) => d.validated) || []; + const customDomain = validatedDomains.find( + (d) => d.type === ReverseProxyDomainType.CUSTOM, + ); + const freeDomain = validatedDomains.find( + (d) => d.type === ReverseProxyDomainType.FREE, + ); + return customDomain?.domain || freeDomain?.domain || ""; + }); + + type EndpointMode = ServiceMode; + + // Endpoint mode derived from protocol field + const [endpointMode, setEndpointMode_] = useState(() => { + if (reverseProxy?.mode) { + const p = reverseProxy.mode; + if (p === ServiceMode.TLS || p === ServiceMode.TCP || p === ServiceMode.UDP) return p; } - return undefined; + return initialEndpointMode ?? ServiceMode.HTTP; }); - const [port, setPort] = useState( - reverseProxy?.targets?.[0]?.port || 0, + // Fetch peers & resources for TLS target selector + const { data: peers } = useFetchApi("/peers"); + const { data: resources } = useFetchApi( + "/networks/resources", ); - const [listenPort, setListenPort] = useState( + // L4 target selection state (TLS/TCP/UDP) - target is in targets[0] + const existingL4Target = isL4ServiceMode(reverseProxy?.mode) ? reverseProxy?.targets?.[0] : undefined; + const existingL4IsPeer = existingL4Target?.target_type === ReverseProxyTargetType.PEER; + + const [tlsTargetType, setTlsTargetType] = useState( + existingL4Target + ? existingL4Target.target_type + : initialResource + ? (initialResource.type as ReverseProxyTargetType) ?? ReverseProxyTargetType.HOST + : ReverseProxyTargetType.PEER, + ); + const [tlsPeerId, setTlsPeerId] = useState( + existingL4IsPeer + ? existingL4Target?.target_id + : initialResource + ? undefined + : initialPeer?.id, + ); + const [tlsResourceId, setTlsResourceId] = useState( + existingL4Target + ? existingL4IsPeer + ? undefined + : existingL4Target.target_id + : initialResource?.id, + ); + const [tlsHost, setTlsHost] = useState(() => { + if (existingL4Target?.host) return existingL4Target.host; + if (initialPeer) return initialPeer.ip; + if (initialResource) { + const addr = initialResource.address; + return addr.includes("/") ? addr.split("/")[0] : addr; + } + return ""; + }); + const [tlsPort, setTlsPort] = useState(existingL4Target?.port || 0); + const [tlsListenPort, setTlsListenPort] = useState( reverseProxy?.listen_port || 0, ); - // CIDR detection for L4 subnet resources - const { isCidrRange: l4IsCidrRange, isValidCidrHost: l4IsValidCidrHost } = - useReverseProxyAddress(l4Target); + const hasTlsTarget = !!tlsPeerId || !!tlsResourceId; + + // CIDR detection for TLS subnet resources + const tlsResourceAddress = useMemo(() => { + if (!tlsResourceId) return ""; + const resource = + resources?.find((r) => r.id === tlsResourceId) ?? + (initialResource?.id === tlsResourceId ? initialResource : undefined); + return resource?.address || ""; + }, [tlsResourceId, resources, initialResource]); + + const tlsCidrInfo = useMemo(() => { + if (!tlsResourceAddress) return null; + if (!cidr.isValidCIDR(tlsResourceAddress)) return null; + try { + return new cidr(tlsResourceAddress); + } catch { + return null; + } + }, [tlsResourceAddress]); + + const tlsIsCidrRange = useMemo(() => { + if (!tlsCidrInfo) return false; + const parts = tlsResourceAddress.split("/"); + const mask = parts.length === 2 ? parseInt(parts[1], 10) : 32; + return mask < 32; + }, [tlsCidrInfo, tlsResourceAddress]); + + const tlsIsHostEditable = tlsIsCidrRange; + + const tlsIsHostInCidrRange = useMemo(() => { + if (!tlsCidrInfo || !tlsHost) return false; + if (!cidr.isValidAddress(tlsHost)) return false; + return tlsCidrInfo.contains(tlsHost); + }, [tlsCidrInfo, tlsHost]); + + const tlsIsValidCidrHost = + !tlsIsCidrRange || (!!tlsHost && tlsIsHostInCidrRange); + + const setEndpointMode = useCallback( + (mode: EndpointMode) => { + setEndpointMode_(mode); + }, + [], + ); // Proxy protocol: for L4 modes maps to target proxy_protocol const [proxyProtocol, setProxyProtocol] = useState( - reverseProxy?.targets?.[0]?.options?.proxy_protocol ?? false, + existingL4Target?.options?.proxy_protocol ?? false, ); - const [timeoutOption, setTimeoutOption] = useState( - reverseProxy?.targets?.[0]?.options?.request_timeout ?? - reverseProxy?.targets?.[0]?.options?.session_idle_timeout ?? - "", + const [requestTimeout, setRequestTimeout] = useState( + existingL4Target?.options?.request_timeout ?? (endpointMode === "udp" ? existingL4Target?.options?.session_idle_timeout ?? "" : ""), + ); + const [sessionIdleTimeout, setSessionIdleTimeout] = useState( + existingL4Target?.options?.session_idle_timeout ?? "", ); - - const timeoutError = useMemo(() => { - if (!timeoutOption) return undefined; - return serviceMode === ServiceMode.UDP - ? validateSessionIdleTimeout(timeoutOption) - : validateTimeout(timeoutOption); - }, [timeoutOption, serviceMode]); const [targets, setTargets] = useState( reverseProxy?.targets || [], ); - const selectedDomain = useMemo( - () => - domains?.find( - (d) => d.domain === baseDomain || d.target_cluster === baseDomain, - ), - [domains, baseDomain], + const isL4Mode = endpointMode === "tls" || endpointMode === "tcp" || endpointMode === "udp"; + // TCP/UDP use port-based routing; TLS uses SNI routing + const isPortBased = endpointMode === "tcp" || endpointMode === "udp"; + + // Check if the selected cluster supports custom listen ports + const selectedClusterDomain = domains?.find( + (d) => d.domain === baseDomain || d.target_cluster === baseDomain, ); + const clusterSupportsCustomPorts = + selectedClusterDomain?.supports_custom_ports ?? false; - // Whether a custom listen port is supported (TLS always, TCP/UDP only when cluster supports it) - const isListenPortSupported = useMemo(() => { - if (serviceMode !== ServiceMode.TCP && serviceMode !== ServiceMode.UDP) - return true; - return selectedDomain?.supports_custom_ports ?? false; - }, [selectedDomain, serviceMode]); + const hasAnyEndpoint = + (endpointMode === "http" && targets.length > 0) || + (isL4Mode && + hasTlsTarget && + tlsIsValidCidrHost && + tlsPort >= 1 && + tlsPort <= 65535 && + (isPortBased && !clusterSupportsCustomPorts + ? true + : tlsListenPort >= 1 && tlsListenPort <= 65535)); const [passHostHeader, setPassHostHeader] = useState( reverseProxy?.pass_host_header ?? false, @@ -213,6 +367,19 @@ export default function ReverseProxyModal({ reverseProxy?.rewrite_redirects ?? false, ); + // Compute full domain + const fullDomain = useMemo(() => { + if (!baseDomain) return subdomain; + return `${subdomain}.${baseDomain}`; + }, [subdomain, baseDomain]); + + const domainAlreadyExists = useMemo(() => { + if (!reverseProxies || !fullDomain) return false; + return reverseProxies.some( + (p) => p.domain === fullDomain && p.id !== reverseProxy?.id, + ); + }, [reverseProxies, fullDomain, reverseProxy?.id]); + // Authentication options - initialized from existing reverseProxy.auth const [passwordEnabled, setPasswordEnabled] = useState( reverseProxy?.auth?.password_auth?.enabled ?? false, @@ -236,10 +403,20 @@ export default function ReverseProxyModal({ reverseProxy?.auth?.link_auth?.enabled ?? false, ); + const [headerAuths, setHeaderAuths] = useState( + reverseProxy?.auth?.header_auths ?? [], + ); + const headerAuthsEnabled = headerAuths.length > 0; + + const [accessRestrictions, setAccessRestrictions] = useState( + reverseProxy?.access_restrictions ?? {}, + ); + // Auth modal states const [passwordModalOpen, setPasswordModalOpen] = useState(false); const [ssoModalOpen, setSsoModalOpen] = useState(false); const [pinModalOpen, setPinModalOpen] = useState(false); + const [headerAuthModalOpen, setHeaderAuthModalOpen] = useState(false); // Target being added/edited const [targetModalOpen, setTargetModalOpen] = useState(false); @@ -247,32 +424,21 @@ export default function ReverseProxyModal({ null, ); + const isSubdomainValid = useMemo(() => { + return ( + subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists + ); + }, [subdomain, baseDomain, domainAlreadyExists]); + const canContinueToSettings = useMemo(() => { - const isSubdomainValid = - subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists; - const isValidPort = (port: number) => port >= 1 && port <= 65535; - const hasHttpEndpoint = !isL4Mode && targets.length > 0; - const hasL4Endpoint = - isL4Mode && - !!l4Target && - l4IsValidCidrHost && - isValidPort(port) && - (!isListenPortSupported || isValidPort(listenPort)); - const hasAnyEndpoint = hasHttpEndpoint || hasL4Endpoint; - return isSubdomainValid && hasAnyEndpoint; - }, [ - subdomain, - baseDomain, - domainAlreadyExists, - serviceMode, - targets.length, - isL4Mode, - l4Target, - l4IsValidCidrHost, - port, - isListenPortSupported, - listenPort, - ]); + if (!isSubdomainValid) return false; + if (!hasAnyEndpoint) return false; + return true; + }, [isSubdomainValid, hasAnyEndpoint]); + + const submitDisabled = useMemo(() => { + return !canContinueToSettings; + }, [canContinueToSettings]); const saveTarget = (targetData: ReverseProxyTarget) => { if (editingTargetIndex !== null) { @@ -306,15 +472,31 @@ export default function ReverseProxyModal({ }; const hasNoAuth = - !passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled; + !passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled && !headerAuthsEnabled; + + // Filter out empty entries (rows where user clicked "Add" but didn't fill in). + const cleanedRestrictions: AccessRestrictions = { + allowed_cidrs: accessRestrictions.allowed_cidrs?.filter(Boolean), + blocked_cidrs: accessRestrictions.blocked_cidrs?.filter(Boolean), + allowed_countries: accessRestrictions.allowed_countries?.filter(Boolean), + blocked_countries: accessRestrictions.blocked_countries?.filter(Boolean), + }; + + const hasAccessRestrictions = + (cleanedRestrictions.allowed_cidrs?.length ?? 0) > 0 || + (cleanedRestrictions.blocked_cidrs?.length ?? 0) > 0 || + (cleanedRestrictions.allowed_countries?.length ?? 0) > 0 || + (cleanedRestrictions.blocked_countries?.length ?? 0) > 0; const handleSubmit = async () => { - // Show warning if no authentication is configured (HTTP only; TLS is pass-through) - if (!isL4Mode && hasNoAuth) { + const isHTTPMode = endpointMode === "http"; + const noProtection = isHTTPMode ? (hasNoAuth && !hasAccessRestrictions) : !hasAccessRestrictions; + if (noProtection) { const confirmed = await confirm({ - title: "No Authentication Configured", - description: - "This service will be publicly accessible to everyone on the internet without any restrictions. Are you sure you want to continue?", + title: "No Protection Configured", + description: isHTTPMode + ? "This service will be publicly accessible to everyone on the internet without any authentication or access restrictions. Are you sure you want to continue?" + : "This service has no access restrictions (IP or country). It will accept connections from any source. Are you sure you want to continue?", type: "warning", confirmText: reverseProxy ? "Save Changes" : "Add Service", cancelText: "Cancel", @@ -341,48 +523,43 @@ export default function ReverseProxyModal({ link_auth: { enabled: linkAuthEnabled, }, + header_auths: headerAuths, }; - const l4TargetPayload: ReverseProxyTarget | undefined = l4Target - ? { - target_id: l4Target?.peerId || l4Target?.resourceId || "", - target_type: l4Target?.type, - port: port, - protocol: - serviceMode === ServiceMode.TLS - ? ReverseProxyTargetProtocol.TCP - : serviceMode === ServiceMode.UDP - ? ReverseProxyTargetProtocol.UDP - : ReverseProxyTargetProtocol.TCP, - host: l4IsCidrRange ? l4Target?.host : undefined, - enabled: true, - options: (() => { - const opts: Record = {}; - if (serviceMode !== ServiceMode.UDP && proxyProtocol) - opts.proxy_protocol = true; - if (timeoutOption) { - opts[ - serviceMode === ServiceMode.UDP - ? "session_idle_timeout" - : "request_timeout" - ] = timeoutOption; - } - return Object.keys(opts).length ? opts : undefined; - })(), - } - : undefined; + const l4TargetType = + tlsTargetType === ReverseProxyTargetType.PEER + ? ReverseProxyTargetType.PEER + : tlsTargetType === ReverseProxyTargetType.SUBNET + ? ReverseProxyTargetType.SUBNET + : ReverseProxyTargetType.HOST; + + const l4Target: ReverseProxyTarget = { + target_id: tlsPeerId || tlsResourceId || "", + target_type: l4TargetType, + port: tlsPort, + protocol: (endpointMode === "tls" ? "tcp" : endpointMode) as ReverseProxyTargetProtocol, + host: tlsIsCidrRange ? tlsHost : undefined, + enabled: true, + options: (endpointMode !== "udp" && proxyProtocol) || requestTimeout || sessionIdleTimeout ? { + ...(endpointMode !== "udp" && proxyProtocol ? { proxy_protocol: true } : {}), + ...(endpointMode === "udp" && requestTimeout ? { session_idle_timeout: requestTimeout } : {}), + ...(endpointMode !== "udp" && requestTimeout ? { request_timeout: requestTimeout } : {}), + ...((endpointMode === "tcp" || endpointMode === "tls") && sessionIdleTimeout ? { session_idle_timeout: sessionIdleTimeout } : {}), + } : undefined, + }; handleCreateOrUpdateProxy({ data: { name: fullDomain, domain: fullDomain, - mode: isL4Mode ? (serviceMode as ServiceMode) : undefined, - listen_port: isL4Mode && isListenPortSupported ? listenPort : undefined, - targets: isL4Mode && l4TargetPayload ? [l4TargetPayload] : targets, + mode: isHTTPMode ? undefined : (endpointMode as ServiceMode), + listen_port: isL4Mode && (!isPortBased || clusterSupportsCustomPorts) ? tlsListenPort : undefined, + targets: isHTTPMode ? targets : [l4Target], enabled: reverseProxy?.enabled ?? true, - pass_host_header: isL4Mode ? undefined : passHostHeader, - rewrite_redirects: isL4Mode ? undefined : rewriteRedirects, - auth: isL4Mode ? undefined : auth, + pass_host_header: isHTTPMode ? passHostHeader : undefined, + rewrite_redirects: isHTTPMode ? rewriteRedirects : undefined, + auth: isHTTPMode ? auth : undefined, + access_restrictions: hasAccessRestrictions ? cleanedRestrictions : undefined, }, proxyId: reverseProxy?.id, onSuccess: () => { @@ -392,20 +569,6 @@ export default function ReverseProxyModal({ }); }; - const modalTitle = useMemo(() => { - const prefix = reverseProxy ? "Edit" : "Add"; - const label = serviceMode ? SERVICE_MODES[serviceMode].label : "Service"; - return `${prefix} ${label}`; - }, [reverseProxy, serviceMode]); - - const modalDescription = useMemo( - () => - isL4Mode - ? "Forward traffic directly to your backend service." - : "Expose services securely through NetBird's reverse proxy.", - [isL4Mode], - ); - return ( } - title={modalTitle} - description={modalDescription} + title={ + reverseProxy + ? "Edit Service" + : endpointMode === "tls" + ? "Add TLS Passthrough" + : endpointMode === "tcp" + ? "Add TCP Service" + : endpointMode === "udp" + ? "Add UDP Service" + : endpointMode === "http" + ? "Add HTTP Service" + : "Add Service" + } + description={ + isL4Mode + ? "Forward traffic directly to your backend." + : "Expose services securely through NetBird's reverse proxy." + } color={"netbird"} /> - - Service + + Details - {!isL4Mode && ( - + {endpointMode === "http" && ( + Authentication )} + + + Access Control + Advanced Settings @@ -438,55 +624,380 @@ export default function ReverseProxyModal({
- - - {!reverseProxy && ( - +
+ + + Enter a subdomain and select a domain for your service. + +
+
+ { + setSubdomain( + e.target.value + .toLowerCase() + .replace(/[^a-z0-9-]/g, ""), + ); + }} + error={ + domainAlreadyExists + ? "This domain is already used by another service." + : undefined + } + placeholder={"myapp"} + className="!rounded-r-none !border-r-0" + /> +
+
+ +
+
+
+ + {reverseProxy?.proxy_cluster && !isClusterConnected && ( + + Cluster {reverseProxy.proxy_cluster} is offline. Make sure the + proxy server is running and connected to the right management + address. + )} - {isL4Mode ? ( - - ) : ( - setTargetModalOpen(true)} - initialNetwork={initialNetwork} - onNavigateToResources={() => { - onOpenChange(false); - router.push( - `/network?id=${initialNetwork?.id}&tab=resources`, - ); - }} - /> +
+ {!initialEndpointMode && !reverseProxy && ( + <> +
+ + + Choose how traffic is forwarded to your backend. + +
+ +
+ {([ + { mode: "http" as EndpointMode, icon: , label: "HTTP", desc: "Reverse proxy with path routing, auth, and load balancing." }, + { mode: "tls" as EndpointMode, icon: , label: "TLS Passthrough", desc: "Direct TCP relay via SNI routing." }, + { mode: "tcp" as EndpointMode, icon: , label: "TCP", desc: "TCP relay to a backend on a dedicated port." }, + { mode: "udp" as EndpointMode, icon: , label: "UDP", desc: "UDP relay to a backend on a dedicated port." }, + ]).map(({ mode, icon, label, desc }) => ( + + ))} +
+ + )} + + {isL4Mode && ( + <> +
+ + + Select the peer or resource running your backend. + + {}} + placeholder="Select a peer or resource..." + showPeers={true} + showResources={true} + showRoutes={false} + hideAllGroup={true} + hideGroupsTab={true} + tabOrder={["peers", "resources"]} + closeOnSelect={true} + max={1} + resource={ + isResourceTargetType(tlsTargetType) && tlsResourceId + ? { id: tlsResourceId, type: "host" } + : tlsTargetType === ReverseProxyTargetType.PEER && tlsPeerId + ? { id: tlsPeerId, type: "peer" } + : undefined + } + onResourceChange={(res) => { + if (res) { + if (res.type === "peer") { + setTlsTargetType(ReverseProxyTargetType.PEER); + setTlsPeerId(res.id); + setTlsResourceId(undefined); + const peer = peers?.find((p) => p.id === res.id); + setTlsHost(peer?.ip || ""); + } else { + const selectedResource = resources?.find( + (r) => r.id === res.id, + ); + setTlsTargetType( + (selectedResource?.type as ReverseProxyTargetType) ?? + ReverseProxyTargetType.HOST, + ); + setTlsResourceId(res.id); + setTlsPeerId(undefined); + const address = selectedResource?.address || ""; + setTlsHost( + address.includes("/") + ? address.split("/")[0] + : address, + ); + } + } else { + setTlsPeerId(undefined); + setTlsResourceId(undefined); + setTlsHost(""); + } + }} + /> +
+
+ + + {!isPortBased || clusterSupportsCustomPorts + ? "The public listen port and the destination port on the target device." + : "The destination port on the target device. The public listen port will be auto-assigned."} + + {tlsCidrInfo && ( + + Enter an IP address within {tlsResourceAddress} + + )} +
+
+ + setTlsListenPort(parseInt(e.target.value) || 0) + } + disabled={isPortBased && !clusterSupportsCustomPorts} + aria-label="Public listen port" + /> +
+ +
+ { + if (tlsIsHostEditable) { + setTlsHost( + e.target.value.replace(/[^0-9.]/g, ""), + ); + } + }} + placeholder={tlsIsHostEditable ? "e.g., 10.0.0.5" : ""} + disabled={!hasTlsTarget} + readOnly={hasTlsTarget && !tlsIsHostEditable} + aria-label="Destination host or IP" + className={ + !tlsIsHostEditable + ? "!text-nb-gray-400 font-mono !text-xs" + : "font-mono !text-xs" + } + /> +
+ + : + +
+ + setTlsPort(parseInt(e.target.value) || 0) + } + aria-label="Destination port" + /> +
+
+
+ + )} +
+ + {endpointMode === "http" && ( +
+ + + Add one or more devices running your service or resources to + make it publicly accessible. + + + {targets.length > 0 && ( +
+ + + {targets.map((target, index) => ( + editTarget(index)} + className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all" + > + + + + + + ))} + +
+ + {target.path + ? target.path.startsWith("/") + ? target.path + : `/${target.path}` + : "/"} + + + + + + +
e.stopPropagation()} + > + + toggleTargetEnabled(index) + } + /> + + + + + + editTarget(index)} + > +
+ + Edit Target +
+
+ removeTarget(index)} + > +
+ + Remove Target +
+
+
+
+
+
+
+ )} + + + + {initialNetwork && !initialNetwork.resources?.length && ( + + } + > + There are currently no resources in your network{" "} + + {initialNetwork?.name} + + . Add resources to your network before exposing it as a + service.{" "} + { + onOpenChange(false); + router.push( + `/network?id=${initialNetwork.id}&tab=resources`, + ); + }} + > + Go to Resources + + + + )} +
)}
@@ -527,66 +1038,97 @@ export default function ReverseProxyModal({ enabled={pinEnabled} onClick={() => setPinModalOpen(true)} /> + + + Header Auth + + } + description="Authenticate via HTTP headers (Basic Auth, Bearer token, or custom). Multiple headers are OR'd." + enabled={headerAuthsEnabled} + onClick={() => setHeaderAuthModalOpen(true)} + /> +

+ When multiple methods are enabled, a request matching any one of them will be granted access. +

+ + + + -
- {(serviceMode === ServiceMode.TCP || - serviceMode === ServiceMode.TLS) && ( +
+ {(endpointMode === "tcp" || endpointMode === "tls") && ( - - Preserve Client Source IP + + PROXY Protocol v2 } - helpText="Preserve client source IP addresses when forwarding traffic to the backend using PROXY Protocol v2." + helpText="Send a PROXY protocol v2 header to the backend with the real client IP." /> )} - {isL4Mode && ( - <> -
-
+
+
+
- - {serviceMode === ServiceMode.UDP ? ( - <> - Close the UDP session after this period of - inactivity. -
Leave this field empty for no timeout. - - ) : ( - <> - Timeout for establishing backend connections.
{" "} - Leave this field empty for no timeout. - - )} + + {endpointMode === "udp" + ? "Close the UDP session after this period of inactivity." + : "Timeout for establishing backend connections."}
- } - placeholder="e.g. 10s, 30s, 1m" - value={timeoutOption} - onChange={(e) => setTimeoutOption(e.target.value)} - maxWidthClass="w-[180px]" - errorTooltip={true} - error={timeoutError} - /> +
+ setRequestTimeout(e.target.value)} + onClick={(e) => e.stopPropagation()} + placeholder={'30s'} + maxWidthClass={"w-[100px]"} + /> +
- + {(endpointMode === "tcp" || endpointMode === "tls") && ( +
+
+ + + Close the TCP connection after this period of inactivity. Defaults to 5m when empty. + +
+
+ setSessionIdleTimeout(e.target.value)} + onClick={(e) => e.stopPropagation()} + placeholder={'5m'} + maxWidthClass={"w-[100px]"} + /> +
+
+ )} +
)} - - {!isL4Mode && ( -
+ {endpointMode === "http" && ( + <> -
+ )}
@@ -617,31 +1159,44 @@ export default function ReverseProxyModal({
- {(() => { - const docsLink = { - targets: { - href: REVERSE_PROXY_SERVICES_DOCS_LINK, - label: "Services", - }, - auth: { - href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK, - label: "Authentication", - }, - settings: { - href: REVERSE_PROXY_SETTINGS_DOCS_LINK, - label: "Settings", - }, - }[tab]; - return docsLink ? ( - - Learn more about - - {docsLink.label} - - - - ) : null; - })()} + {tab === "targets" && ( + + Learn more about + + Services + + + + )} + + {tab === "auth" && ( + + Learn more about + + Authentication + + + + )} + + {tab === "settings" && ( + + Learn more about + + Settings + + + + )}
{!reverseProxy ? ( @@ -651,21 +1206,53 @@ export default function ReverseProxyModal({ + {endpointMode === "udp" ? ( + + ) : ( + + )} + + )} + + {tab === "auth" && ( + <> + )} - {tab === "auth" && ( + {tab === "access" && ( <> @@ -682,17 +1269,13 @@ export default function ReverseProxyModal({ <> + )} +
+ + {!draft.existingSecret &&
+ Type + +
} + + {draft.existingSecret ? ( +
+ + Header: {draft.header} + +

+ Value is configured. Enter a new value below to change it, or leave empty to keep the current one. +

+ { + if (e.target.value) { + updateDraft(index, { + existingSecret: false, + preset: "custom", + value: e.target.value, + }); + } + }} + /> +
+ ) : ( + <> + {draft.preset === "basic" && ( + <> +
+ Username + updateDraft(index, { username: e.target.value })} + placeholder="admin" + autoComplete="off" + data-1p-ignore + /> +
+
+ Password + updateDraft(index, { password: e.target.value })} + placeholder="Enter password..." + autoComplete="off" + data-1p-ignore + data-lpignore="true" + /> +
+ + )} + + {draft.preset === "bearer" && ( +
+ Token + updateDraft(index, { value: e.target.value })} + placeholder="Enter bearer token..." + autoComplete="off" + data-1p-ignore + data-lpignore="true" + /> +
+ )} + + {draft.preset === "custom" && ( + <> +
+ Header Name + updateDraft(index, { header: e.target.value })} + placeholder="X-API-Key" + autoComplete="off" + /> +
+
+ Header Value + updateDraft(index, { value: e.target.value })} + placeholder="Enter expected value..." + autoComplete="off" + data-1p-ignore + data-lpignore="true" + /> +
+ + )} + + )} +
+ ))} + + + +

+ When multiple headers are configured, a request matching any one of them will be granted access. + Each header is stripped before forwarding to the backend. +

+ +
+ {isEditing ? ( + <> + +
+ + + + +
+ + ) : ( + <> +
+
+ + + + +
+ + )} +
+
+ + + ); +} diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx index 66313c3b..bb78a670 100644 --- a/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx +++ b/src/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell.tsx @@ -1,5 +1,5 @@ import Badge from "@components/Badge"; -import { Binary, Mail, RectangleEllipsis, Users } from "lucide-react"; +import { Binary, Globe, KeyRound, Mail, Network, RectangleEllipsis, ShieldOff, Users } from "lucide-react"; import * as React from "react"; import { ReverseProxyEvent } from "@/interfaces/ReverseProxy"; @@ -33,6 +33,11 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => { icon: , label: "PIN Code", }; + case "header": + return { + icon: , + label: "Header Auth", + }; case "link": case "magic_link": case "magic-link": @@ -40,6 +45,21 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => { icon: , label: "Magic Link", }; + case "ip_restricted": + return { + icon: , + label: "IP Restricted", + }; + case "country_restricted": + return { + icon: , + label: "Country Restricted", + }; + case "geo_unavailable": + return { + icon: , + label: "Geo Unavailable", + }; default: return { icon: null, diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx index 324dd152..9ac2acf6 100644 --- a/src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx +++ b/src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx @@ -19,8 +19,8 @@ export const ReverseProxyEventsLocationIpCell = ({ event }: Props) => { const { getRegionText, isLoading } = useCountries(); const region = useMemo(() => { - return getRegionText(event.country_code || "", event.city_name || ""); - }, [getRegionText, event.country_code, event.city_name]); + return getRegionText(event.country_code || "", event.city_name || "", event.subdivision_code); + }, [getRegionText, event.country_code, event.city_name, event.subdivision_code]); return ( - - Auth methods are not supported for TCP/UDP and TLS passthrough - services as they operate at the network layer. -
- } - > - - N/A - - - -
+ ); } const auth = reverseProxy.auth; const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled); + const headerAuthCount = auth?.header_auths?.length ?? 0; + const totalEnabled = enabled.length + (headerAuthCount > 0 ? 1 : 0); const ssoGroups = auth?.bearer_auth?.enabled ? (auth.bearer_auth.distribution_groups ?? []) @@ -91,21 +84,30 @@ export default function ReverseProxyAuthCell({ : []; const showHoverContent = - enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled); - - const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null; - - const badgeContent = SingleIcon ? ( - <> - - {enabled[0].label} - - ) : enabled.length > 1 ? ( - <> - - {enabled.length} Enabled - - ) : null; + totalEnabled > 1 || (totalEnabled === 1 && auth?.bearer_auth?.enabled); + + const isSingleStandard = totalEnabled === 1 && enabled.length === 1; + const SingleIcon = isSingleStandard ? enabled[0].Icon : null; + + const badgeContent = + isSingleStandard && SingleIcon ? ( + <> + + {enabled[0].label} + + ) : totalEnabled === 1 && headerAuthCount > 0 ? ( + <> + + Header Auth + + ) : totalEnabled > 1 ? ( + <> + + + {totalEnabled} Enabled + + + ) : null; return (
(
@@ -190,3 +194,112 @@ export default function ReverseProxyAuthCell({
); } + +function hasRestrictions(r?: AccessRestrictions): boolean { + if (!r) return false; + return ( + (r.allowed_cidrs?.length ?? 0) > 0 || + (r.blocked_cidrs?.length ?? 0) > 0 || + (r.allowed_countries?.length ?? 0) > 0 || + (r.blocked_countries?.length ?? 0) > 0 + ); +} + +type L4AccessBadgeProps = { + reverseProxy: ReverseProxy; + permission: ReturnType["permission"]; + openModal: ReturnType["openModal"]; +}; + +function L4AccessBadge({ + reverseProxy, + permission, + openModal, +}: Readonly) { + const r = reverseProxy.access_restrictions; + const active = hasRestrictions(r); + + return ( +
{ + e.stopPropagation(); + openModal({ proxy: reverseProxy, initialTab: "access" }); + }} + > + + + {active ? ( + + + + Access Control + + + ) : ( + + + No Restrictions + + )} + + {active && r && ( + e.stopPropagation()} + > +
+ {(r.allowed_cidrs?.length ?? 0) > 0 && ( + } + label={`${r.allowed_cidrs!.length} CIDR${r.allowed_cidrs!.length > 1 ? "s" : ""} allowed`} + value={Active} + /> + )} + {(r.blocked_cidrs?.length ?? 0) > 0 && ( + } + label={`${r.blocked_cidrs!.length} CIDR${r.blocked_cidrs!.length > 1 ? "s" : ""} blocked`} + value={Active} + /> + )} + {(r.allowed_countries?.length ?? 0) > 0 && ( + } + label={`${r.allowed_countries!.length} ${r.allowed_countries!.length > 1 ? "countries" : "country"} allowed`} + value={Active} + /> + )} + {(r.blocked_countries?.length ?? 0) > 0 && ( + } + label={`${r.blocked_countries!.length} ${r.blocked_countries!.length > 1 ? "countries" : "country"} blocked`} + value={Active} + /> + )} +
+
+ )} +
+ + +
+ ); +} diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx index 88ff09b2..8272d3e3 100644 --- a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx +++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx @@ -1,11 +1,5 @@ "use client"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@components/Accordion"; import Button from "@components/Button"; import FancyToggleSwitch from "@components/FancyToggleSwitch"; import HelpText from "@components/HelpText"; @@ -13,16 +7,22 @@ import { Input } from "@components/Input"; import { Label } from "@components/Label"; 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, PlusCircle, Server, + Settings, ShieldXIcon, + Text, } from "lucide-react"; import { Callout } from "@components/Callout"; +import cidr from "ip-cidr"; import React, { useMemo, useRef, useState } from "react"; import { Network, NetworkResource } from "@/interfaces/Network"; import { Peer } from "@/interfaces/Peer"; @@ -41,18 +41,11 @@ import { } from "@/contexts/ReverseProxiesProvider"; import { cn } from "@utils/helpers"; import { HelpTooltip } from "@components/HelpTooltip"; -import InlineLink from "@components/InlineLink"; +import InlineLink, { InlineButtonLink } from "@components/InlineLink"; +import SetupModal from "@/modules/setup-netbird-modal/SetupModal"; import Paragraph from "@components/Paragraph"; import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders"; -import ReverseProxyTargetSelector, { - Target, -} from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector"; import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions"; -import ReverseProxyAddressInput, { - CidrHelpText, - useReverseProxyAddress, -} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput"; -import Separator from "@components/Separator"; /** Get initial host value based on target, resource, or peer */ function getInitialHost( @@ -93,33 +86,38 @@ export default function ReverseProxyTargetModal({ }: Readonly) { const existingTargets = reverseProxy.targets || []; const domain = reverseProxy.domain; - - const [target, setTarget] = useState( - currentTarget || initialResource || initialPeer - ? { - type: - currentTarget?.target_type ?? - (initialResource - ? (initialResource.type as ReverseProxyTargetType) ?? - ReverseProxyTargetType.HOST - : ReverseProxyTargetType.PEER), - peerId: - currentTarget?.target_type === ReverseProxyTargetType.PEER - ? currentTarget?.target_id - : initialPeer?.id, - resourceId: - currentTarget && isResourceTargetType(currentTarget.target_type) - ? currentTarget?.target_id - : initialResource?.id, - host: getInitialHost(currentTarget, initialResource, initialPeer), - } - : undefined, + // Fetch resources and peers for target selection + const { data: resources } = useFetchApi( + "/networks/resources", ); + const { data: peers } = useFetchApi("/peers"); + const [tab, setTab] = useState("details"); + + const [targetType, setTargetType] = useState( + currentTarget?.target_type ?? + (initialResource + ? (initialResource.type as ReverseProxyTargetType) ?? + ReverseProxyTargetType.HOST + : ReverseProxyTargetType.PEER), + ); + const [targetPeerId, setTargetPeerId] = useState( + currentTarget?.target_type === ReverseProxyTargetType.PEER + ? currentTarget?.target_id + : initialPeer?.id, + ); + const [targetResourceId, setTargetResourceId] = useState( + currentTarget && isResourceTargetType(currentTarget.target_type) + ? currentTarget?.target_id + : initialResource?.id, + ); const [targetProtocol, setTargetProtocol] = useState( currentTarget?.protocol ?? ReverseProxyTargetProtocol.HTTP, ); + const [targetHost, setTargetHost] = useState( + getInitialHost(currentTarget, initialResource, initialPeer), + ); const [targetPort, setTargetPort] = useState( currentTarget?.port ?? 0, ); @@ -128,9 +126,50 @@ export default function ReverseProxyTargetModal({ const [options, setOption, { getTargetOptions, headers, errors }] = useReverseProxyTargetOptions(currentTarget?.options); const portInputRef = useRef(null); + const [installModal, setInstallModal] = useState(false); + + // Get the current resource's address (from initialResource or selected resource) + const currentResourceAddress = useMemo(() => { + if (initialResource) return initialResource.address; + if (targetResourceId) { + const resource = resources?.find((r) => r.id === targetResourceId); + return resource?.address || ""; + } + return ""; + }, [initialResource, targetResourceId, resources]); + + // Parse the CIDR using ip-cidr library + const cidrInfo = useMemo(() => { + if (!currentResourceAddress) return null; + if (!cidr.isValidCIDR(currentResourceAddress)) return null; + try { + return new cidr(currentResourceAddress); + } catch { + return null; + } + }, [currentResourceAddress]); + + // Get the CIDR mask (e.g., 24 for /24) + const cidrMask = useMemo(() => { + if (!cidrInfo) return null; + const parts = currentResourceAddress.split("/"); + return parts.length === 2 ? parseInt(parts[1], 10) : 32; + }, [cidrInfo, currentResourceAddress]); + + // Check if address is a CIDR range (has more than one address) + const isCidrRange = useMemo(() => { + return cidrMask !== null && cidrMask < 32; + }, [cidrMask]); + + // Host should be editable if it's a CIDR range with multiple addresses + const isHostEditable = isCidrRange; - const { isCidrRange, isHostEditable, isValidCidrHost } = - useReverseProxyAddress(target); + // Validate if current host is within CIDR range + const isHostInCidrRange = useMemo(() => { + if (!cidrInfo || !targetHost) return false; + if (!cidr.isValidAddress(targetHost)) return false; + return cidrInfo.contains(targetHost); + }, [cidrInfo, targetHost]); // Normalize path for comparison (ensure it starts with / and handle empty as /) const normalizePath = (path: string | undefined) => { @@ -158,6 +197,7 @@ export default function ReverseProxyTargetModal({ const isValidPort = targetPort === 0 || (targetPort >= 1 && targetPort <= 65535); + const isValidCidrHost = !isCidrRange || (targetHost && isHostInCidrRange); const canAddTarget = useMemo(() => { // Don't allow if path is duplicate or port is invalid @@ -170,12 +210,11 @@ export default function ReverseProxyTargetModal({ if (initialPeer) { return true; } - if (!target) return false; - if (target.type === ReverseProxyTargetType.PEER) { - return !!target.peerId; + if (targetType === ReverseProxyTargetType.PEER) { + return !!targetPeerId; } - if (isResourceTargetType(target.type)) { - return !!target.resourceId && isValidCidrHost; + if (isResourceTargetType(targetType)) { + return !!targetResourceId && isValidCidrHost; } return false; }, [ @@ -183,30 +222,28 @@ export default function ReverseProxyTargetModal({ isValidPort, initialResource, initialPeer, - target, + targetType, + targetPeerId, + targetResourceId, isValidCidrHost, ]); - const hasTarget = !!(initialResource || initialPeer || target); + const hasTarget = + initialResource || initialPeer || targetPeerId || targetResourceId; const handleSave = () => { - if (!target) return; - const resolvedType = initialPeer - ? ReverseProxyTargetType.PEER - : target.type; + const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType; const resolvedIsResource = isResourceTargetType(resolvedType) || !!initialResource; const targetData: ReverseProxyTarget = { target_type: resolvedType, target_id: resolvedType === ReverseProxyTargetType.PEER - ? target.peerId - : target.resourceId, + ? targetPeerId + : targetResourceId, protocol: targetProtocol, host: - resolvedType === ReverseProxyTargetType.SUBNET - ? target.host - : undefined, + resolvedType === ReverseProxyTargetType.SUBNET ? targetHost : undefined, port: targetPort, path: targetPath || undefined, enabled: currentTarget?.enabled ?? true, @@ -228,291 +265,425 @@ export default function ReverseProxyTargetModal({ color="netbird" /> - + + + + + Details + + + + Advanced Settings + + -
- {!initialResource && !initialPeer && ( - { - setTarget(selection); - if (selection) { - setTimeout(() => portInputRef.current?.focus(), 0); - } - }} - /> - )} - -
- - - 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); - if (!value || value === "/") { - setOption("path_rewrite", undefined); - } - }} - /> -
- {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 + +
+ {!initialResource && !initialPeer && ( +
+
-
- When enabled, a request to e.g.,{" "} - - {targetPath}/users + + } + interactive={true} + > + Peer + {" "} + or{" "} + + A{" "} + + resource {" "} - is forwarded as{" "} - - {targetPath}/users + 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. - . -
-
+ + } + interactive={true} + > + Resource + + + )} + + + + {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); + } } - /> - - } - helpText={ -
- Keep the original full request path when forwarding.{" "} -
- When disabled the matched prefix path is stripped. -
- } - /> + setTimeout(() => portInputRef.current?.focus(), 0); + } else { + setTargetPeerId(undefined); + setTargetResourceId(undefined); + setTargetHost(""); + } + }} + /> +
)} -
-
-
-
- -
-
- { - 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} - /> -
-
- +
+ + + Specify an optional path from where requests are routed to + your service. + +
+
+ {domain || "domain.example.com"}
-
-
-
- -
- setTargetPort(parseInt(e.target.value) || 0) - } - placeholder={String( - defaultPortForProtocol(targetProtocol), - )} - min={0} - max={65535} + placeholder="/" + value={targetPath} + className={cn("rounded-l-none")} + maxWidthClass="w-full" disabled={!hasTarget} - autoFocus={!!initialResource && !isHostEditable} + onChange={(e) => { + let value = e.target.value; + if (value && !value.startsWith("/")) { + value = "/" + value; + } + setTargetPath(value); + if (!value || value === "/") { + setOption("path_rewrite", undefined); + } + }} />
-
-
- {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." - /> - )} -
- - - - - - Optional Settings - - - -
-
-
- - - 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) => + {isPathDuplicate && hasTarget && ( + + } + > + This location is already used by another target and cannot + be added.
Please use a different location. +
+ )} + {targetPath && + targetPath !== "/" && + hasTarget && + !isPathDuplicate && ( + setOption( - "request_timeout", - e.target.value || undefined, + "path_rewrite", + v + ? ("preserve" as ServiceTargetOptionsPathRewrite) + : undefined, ) } - maxWidthClass="w-[180px]" - errorTooltip={true} - error={errors.timeout} + 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. +
+ } /> -
+ )} +
- {reverseProxy.mode === ServiceMode.UDP && ( -
-
- - - How long a UDP session stays alive without traffic - (max 10m).
Defaults to 30s when empty. -
+
+
+
+ + {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 && ( +   + )} +
} - placeholder="e.g. 30s, 2m, 5m" - value={options.session_idle_timeout ?? ""} + ref={portInputRef} + type="number" + value={targetPort === 0 ? "" : targetPort} onChange={(e) => - setOption( - "session_idle_timeout", - e.target.value || undefined, - ) + setTargetPort(parseInt(e.target.value) || 0) } - maxWidthClass="w-[180px]" - errorTooltip={true} - error={errors.sessionIdleTimeout} + 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." + /> )} +
+
+ - + +
+
+
+ + + Max time to wait for a response as duration string (e.g. + 30s, 2m).
Leave this field empty for no timeout. +
- - - -
+ } + placeholder="e.g. 10s, 30s, 1m" + value={options.request_timeout ?? ""} + onChange={(e) => + setOption("request_timeout", e.target.value || undefined) + } + maxWidthClass="w-[180px]" + errorTooltip={true} + error={errors.timeout} + /> +
+ + {(reverseProxy.mode === ServiceMode.UDP || reverseProxy.mode === ServiceMode.TCP) && ( +
+
+ + + {reverseProxy.mode === ServiceMode.UDP + ? <>How long a UDP session stays alive without traffic.
Defaults to 30s when empty. + : <>How long a TCP connection stays alive without traffic.
Defaults to 5m when empty. + } +
+
+ } + placeholder="e.g. 30s, 2m, 5m" + value={options.session_idle_timeout ?? ""} + onChange={(e) => + setOption( + "session_idle_timeout", + e.target.value || undefined, + ) + } + maxWidthClass="w-[180px]" + errorTooltip={true} + error={errors.sessionIdleTimeout} + /> +
+ )} + + +
+ +
@@ -528,27 +699,69 @@ 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 index 75dd70b1..e83ed4b4 100644 --- a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts +++ b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts @@ -7,43 +7,17 @@ import { // 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 -const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m - -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; -} - -export function validateTimeout(timeout: string): string | undefined { +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 validateSessionIdleTimeout(timeout: string): string | undefined { +function validateSessionIdleTimeout(timeout: string): string | undefined { if (!timeout) return undefined; if (!DURATION_RE.test(timeout)) return 'Invalid duration, use e.g., "30s", "2m", "5m"'; - if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS) - return "Session idle timeout cannot exceed the maximum of 10m."; return undefined; }