Compare commits

...
Author SHA1 Message Date
Maycon Santos 7400ac806e remove self-hosted proxies menu item (#640) 2026-05-14 17:50:07 +02:00
Viktor Liu 240ff5af9a Fix IPv6 input across reverse proxy, routes and resources (#638) 2026-05-14 16:43:02 +02:00
Eduard Gert dc86c30463 Add self-hosted proxies (#636)
* Add self-hosted proxies

* fix selfhosted badge for domain
2026-05-12 15:22:12 +02:00
Nicolas Frati e58f75ae3c Enable MFA for local users toggle (#615)
* implement enable mfa for local users toggle

* fix visibility check

* Added beta badge to MFA auth toggle
2026-05-08 16:51:17 +02:00
Viktor Liu dc1adebd27 Add IPv6 overlay settings and peer display (#594) 2026-05-07 15:20:12 +02:00
43 changed files with 1480 additions and 177 deletions

No files matched your search

+103 -119
View File
@@ -2,7 +2,6 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
@@ -72,6 +71,7 @@ import ReverseProxiesProvider, {
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
@@ -469,31 +469,55 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
const { update } = usePeer();
const { mutate } = useSWRConfig();
const [showEditIPModal, setShowEditIPModal] = useState(false);
const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false);
const { permission } = usePermissions();
const countryText = useMemo(() => {
return getRegionByPeer(peer);
}, [getRegionByPeer, peer]);
const handleSaveIP = (newIP: string) => {
notify({
title: peer.name,
description: "NetBird Peer IP was successfully updated",
promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPModal(false);
}),
loadingMessage: "Updating peer IP...",
});
};
const handleSaveIPv6 = (newIPv6: string) => {
notify({
title: peer.name,
description: "NetBird Peer IPv6 was successfully updated",
promise: update({ ipv6: newIPv6 }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPv6Modal(false);
}),
loadingMessage: "Updating peer IPv6...",
});
};
return (
<>
<Modal open={showEditIPModal} onOpenChange={setShowEditIPModal}>
<EditIPModal
onSuccess={(newIP) => {
notify({
title: peer.name,
description: "Peer IP was successfully updated",
promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPModal(false);
}),
loadingMessage: "Updating peer IP...",
});
}}
peer={peer}
key={showEditIPModal ? 1 : 0}
/>
</Modal>
<PeerEditIPModal
version="v4"
currentIP={peer.ip}
open={showEditIPModal}
onOpenChange={setShowEditIPModal}
onSave={handleSaveIP}
key={showEditIPModal ? "v4-open" : "v4-closed"}
/>
<PeerEditIPModal
version="v6"
currentIP={peer.ipv6 || ""}
open={showEditIPv6Modal}
onOpenChange={setShowEditIPv6Modal}
onSave={handleSaveIPv6}
key={showEditIPv6Modal ? "v6-open" : "v6-closed"}
/>
<Card className={"w-full xl:w-1/2"}>
<Card.List>
<Card.ListItem
@@ -502,35 +526,48 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"NetBird IP Address"}
label={
<>
<MapPin size={16} />
<MapPin size={16} className={"shrink-0"} />
NetBird IP Address
</>
}
valueToCopy={peer.ip}
value={
<div className="flex items-center gap-2 justify-between w-full">
<span>{peer.ip}</span>
{permission.peers.update && (
<button
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setShowEditIPModal(true);
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
<EditableValue
value={peer.ip}
canEdit={permission.peers.update}
onEdit={() => setShowEditIPModal(true)}
/>
}
/>
{peer.ipv6 && (
<Card.ListItem
copy
tooltip={false}
copyText={"NetBird IPv6 Address"}
label={
<>
<MapPin size={16} className={"shrink-0"} />
NetBird IPv6 Address
</>
}
valueToCopy={peer.ipv6}
value={
<EditableValue
value={peer.ipv6}
canEdit={permission.peers.update}
onEdit={() => setShowEditIPv6Modal(true)}
/>
}
/>
)}
<Card.ListItem
copy
copyText={"Public IP Address"}
label={
<>
<NetworkIcon size={16} />
<NetworkIcon size={16} className={"shrink-0"} />
Public IP Address
</>
}
@@ -542,7 +579,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"DNS label"}
label={
<>
<Globe size={16} />
<Globe size={16} className={"shrink-0"} />
Domain Name
</>
}
@@ -560,7 +597,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"Hostname"}
label={
<>
<MonitorSmartphoneIcon size={16} />
<MonitorSmartphoneIcon size={16} className={"shrink-0"} />
Hostname
</>
}
@@ -570,7 +607,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<FlagIcon size={16} />
<FlagIcon size={16} className={"shrink-0"} />
Region
</>
}
@@ -600,7 +637,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<Cpu size={16} />
<Cpu size={16} className={"shrink-0"} />
Operating System
</>
}
@@ -611,7 +648,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<Barcode size={16} />
<Barcode size={16} className={"shrink-0"} />
Serial Number
</>
}
@@ -623,7 +660,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<CalendarDays size={16} />
<CalendarDays size={16} className={"shrink-0"} />
Registered on
</>
}
@@ -639,7 +676,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<History size={16} />
<History size={16} className={"shrink-0"} />
Last seen
</>
}
@@ -656,7 +693,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<NetBirdIcon size={16} />
<NetBirdIcon size={16} className={"shrink-0"} />
Agent Version
</>
}
@@ -667,7 +704,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<NetBirdIcon size={16} />
<NetBirdIcon size={16} className={"shrink-0"} />
UI Version
</>
}
@@ -765,82 +802,29 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
);
}
interface EditIPModalProps {
onSuccess: (ip: string) => void;
peer: Peer;
}
function EditIPModal({ onSuccess, peer }: Readonly<EditIPModalProps>) {
const [ip, setIP] = useState(peer.ip);
const [error, setError] = useState("");
const validateIP = (ipAddress: string) => {
const ipRegex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipRegex.test(ipAddress);
};
const isDisabled = useMemo(() => {
if (ip === peer.ip) return true;
const trimmedIP = trim(ip);
return trimmedIP.length === 0 || !validateIP(ip);
}, [ip, peer.ip]);
React.useEffect(() => {
switch (true) {
case ip === peer.ip:
setError("");
break;
case !validateIP(ip):
setError("Please enter a valid IP, e.g., 100.64.0.15");
break;
default:
setError("");
break;
}
}, [ip, peer.ip]);
function EditableValue({
value,
canEdit,
onEdit,
}: {
value: string;
canEdit: boolean;
onEdit: () => void;
}) {
return (
<ModalContent maxWidthClass={"max-w-md"}>
<form>
<ModalHeader
title={"Edit Peer IP Address"}
description={"Update the NetBird IP address for this peer."}
color={"blue"}
/>
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
placeholder={"e.g., 100.64.0.15"}
value={ip}
onChange={(e) => setIP(e.target.value)}
error={error}
/>
</div>
<Callout>Changes take effect when the peer reconnects.</Callout>
</div>
<ModalFooter className={"items-center"} separator={false}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => onSuccess(ip)}
disabled={isDisabled}
>
Save
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
<div className="flex items-center gap-2 justify-between w-full">
<span>{value}</span>
{canEdit && (
<button
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
);
}
@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Self-Hosted Proxies - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;
@@ -0,0 +1,68 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const SelfHostedProxiesTable = lazy(
() =>
import(
"@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesTable"
),
);
export default function ReverseProxyClustersPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/self-hosted-proxies"}
label={"Self-Hosted Proxies"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Self-Hosted Proxies</h1>
<Paragraph>
Setup self-hosted proxies on your own infrastructure for full control
over traffic and geographic location.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
Self-Hosted Proxies
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Self-Hosted Proxies"}
hasAccess={permission?.services?.read}
>
<Suspense fallback={<SkeletonTable />}>
<SelfHostedProxiesTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}
+13 -6
View File
@@ -18,7 +18,7 @@ import {
} from "@utils/version";
export default function SSHPage() {
const { peerId, username, port } = useSSHQueryParams();
const { peerId, username, port, ipVersion } = useSSHQueryParams();
const {
data: peer,
@@ -48,6 +48,7 @@ export default function SSHPage() {
peer={peer}
username={username}
port={port}
ipVersion={ipVersion}
/>
) : (
<LoadingMessage message={"Starting ssh session..."} />
@@ -60,9 +61,10 @@ type Props = {
username: string;
port: string;
peer: Peer;
ipVersion: string | null;
};
function SSHTerminal({ username, port, peer }: Props) {
function SSHTerminal({ username, port, peer, ipVersion }: Props) {
const client = useNetBirdClient();
const connected = useRef(false);
const sshConnectedOnce = useRef(false);
@@ -81,9 +83,12 @@ function SSHTerminal({ username, port, peer }: Props) {
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
// Use the FQDN when an IP version is specified so the dialer resolves to the correct address family.
const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip;
useEffect(() => {
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
}, [username, peer, client]);
document.title = `${username}@${sshHost} - ${peer.hostname}`;
}, [username, peer, client, sshHost]);
const handleReconnect = async () => {
if (!peer?.id) return;
@@ -97,9 +102,10 @@ function SSHTerminal({ username, port, peer }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
} catch (error) {
console.error("Reconnection failed:", error);
@@ -123,9 +129,10 @@ function SSHTerminal({ username, port, peer }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;
+3 -3
View File
@@ -50,11 +50,11 @@ function CardListItem({
return (
<li
className={cn(
"flex justify-between px-4 border-b border-nb-gray-900 py-4 last:border-b-0 items-center h-full",
"flex justify-between px-4 border-b border-nb-gray-900 py-3.5 last:border-b-0 items-center h-full",
className,
)}
>
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
<div className={"flex gap-2.5 items-center text-[0.84rem]"}>{label}</div>
<div className={"flex flex-col gap-2"}>
<CardTextItem
label={label}
@@ -100,7 +100,7 @@ const CardTextItem = ({
return (
<div
className={cn(
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
"text-right text-nb-gray-400 text-[0.84rem] flex items-center gap-2",
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>
+129
View File
@@ -0,0 +1,129 @@
import useCopyToClipboard from "@hooks/useCopyToClipboard";
import { cn } from "@utils/helpers";
import { Copy } from "lucide-react";
import React from "react";
type CardTableProps = {
children: React.ReactNode;
className?: string;
};
function CardTable({ children, className }: CardTableProps) {
return (
<div
className={cn(
"bg-nb-gray-940 rounded-md border border-nb-gray-900 w-full overflow-hidden",
className,
)}
>
<table className={"w-full border-collapse text-sm"}>{children}</table>
</div>
);
}
function CardTableHeader({ children, className }: CardTableProps) {
return (
<thead>
<tr
className={cn(
"border-b border-nb-gray-900",
className,
)}
>
{children}
</tr>
</thead>
);
}
type CardTableHeaderCellProps = {
children: React.ReactNode;
width?: number;
className?: string;
};
function CardTableHeaderCell({
children,
width,
className,
}: CardTableHeaderCellProps) {
return (
<th
className={cn(
"px-4 py-2.5 text-left text-sm font-normal",
className,
)}
style={width ? { width } : undefined}
>
{children}
</th>
);
}
function CardTableBody({ children, className }: CardTableProps) {
return <tbody className={className}>{children}</tbody>;
}
type CardTableRowProps = {
children: React.ReactNode;
className?: string;
};
function CardTableRow({ children, className }: CardTableRowProps) {
return (
<tr
className={cn(
"border-b border-nb-gray-900 last:border-b-0",
className,
)}
>
{children}
</tr>
);
}
type CardTableCellProps = {
children: React.ReactNode;
copy?: boolean;
copyText?: string;
width?: number;
className?: string;
};
function CardTableCell({
children,
copy = false,
copyText,
width,
className,
}: CardTableCellProps) {
const [, copyToClipBoard] = useCopyToClipboard(copyText ?? "");
return (
<td
className={cn("px-4 py-3", className)}
style={width ? { width } : undefined}
>
<div
className={cn(
"text-nb-gray-400 text-sm flex items-center gap-2",
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>
copy &&
copyToClipBoard(`${copyText} has been copied to clipboard.`)
}
>
{children}
{copy && <Copy size={13} className={"shrink-0"} />}
</div>
</td>
);
}
CardTable.Header = CardTableHeader;
CardTable.HeaderCell = CardTableHeaderCell;
CardTable.Body = CardTableBody;
CardTable.Row = CardTableRow;
CardTable.Cell = CardTableCell;
export default CardTable;
+3 -5
View File
@@ -290,7 +290,7 @@ export function PeerGroupSelector({
const searchPlaceholder = useMemo(() => {
if (tab === "groups") return placeholderForSearch;
if (tab === "resources") return "Search resource...";
if (tab === "peers") return "Search peer...";
if (tab === "peers") return "Search peer by name or ip...";
return "Search...";
}, [tab, placeholderForSearch]);
@@ -537,9 +537,6 @@ export function PeerGroupSelector({
const isSelected =
values.find((group) => group.name == option.name) !=
undefined;
const peerCount =
option.peers?.length ?? option?.peers_count ?? 0;
const isDisabled = disabledGroups
? disabledGroups?.findIndex(
(g) => g.id === option.id,
@@ -968,7 +965,8 @@ const ResourcesList = ({
const peersSearchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().includes(lowerCaseQuery);
if (item.ip.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ipv6?.toLowerCase().includes(lowerCaseQuery) ?? false;
};
const PeersList = ({
+2 -3
View File
@@ -30,7 +30,8 @@ const searchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().startsWith(lowerCaseQuery);
if (item.ip.toLowerCase().startsWith(lowerCaseQuery)) return true;
return !!item.ipv6?.toLowerCase().startsWith(lowerCaseQuery);
};
export function PeerSelector({
@@ -124,7 +125,6 @@ export function PeerSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
}
>
<MapPinIcon />
{value.ip}
</div>
</div>
@@ -238,7 +238,6 @@ export function PeerSelector({
!isSupported && "opacity-50",
)}
>
<MapPinIcon />
{option.ip}
</div>
</FullTooltip>
+3
View File
@@ -30,6 +30,7 @@ const PeerContext = React.createContext(
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => Promise<Peer>;
toggleSSH: (newState: boolean) => Promise<void>;
setSSHInstructionsModal: (open: boolean) => void;
@@ -80,6 +81,7 @@ export default function PeerProvider({
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => {
return peerRequest.put(
{
@@ -99,6 +101,7 @@ export default function PeerProvider({
? undefined
: props.approval_required,
ip: props.ip != undefined ? props.ip : undefined,
ipv6: props.ipv6 != undefined ? props.ipv6 : undefined,
},
`/${peer.id}`,
);
+31 -2
View File
@@ -2,6 +2,7 @@
import { notify } from "@components/Notification";
import useFetchApi, { useApiCall } from "@utils/api";
import { wrapIPv6 } from "@utils/ip";
import React, {
createContext,
useCallback,
@@ -15,6 +16,7 @@ import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
ReverseProxy,
ReverseProxyCluster,
ReverseProxyDomain,
ReverseProxyFlatTarget,
ReverseProxyTarget,
@@ -23,6 +25,7 @@ import {
} from "@/interfaces/ReverseProxy";
import ReverseProxyModal from "@/modules/reverse-proxy/ReverseProxyModal";
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
import { usePermissions } from "@/contexts/PermissionsProvider";
type ReverseProxiesContextValue = {
reverseProxies: ReverseProxy[] | undefined;
@@ -51,6 +54,9 @@ type ReverseProxiesContextValue = {
domain: string,
targetCluster: string,
) => Promise<ReverseProxyDomain>;
clusters: ReverseProxyCluster[] | undefined;
isClustersLoading: boolean;
isSelfHostedCluster: (clusterAddress?: string) => boolean;
};
type OpenModalOptions = {
@@ -90,10 +96,14 @@ export default function ReverseProxiesProvider({
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const { permission } = usePermissions();
// Reverse Proxies
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
"/reverse-proxies/services",
false,
true,
permission?.services.read,
);
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
@@ -101,6 +111,9 @@ export default function ReverseProxiesProvider({
const { data: peers } = useFetchApi<Peer[]>("/peers");
const { data: resources } = useFetchApi<NetworkResource[]>(
"/networks/resources",
false,
true,
permission?.services.read,
);
const resolveDestination = useCallback(
@@ -125,12 +138,25 @@ export default function ReverseProxiesProvider({
// Domains
const { data: domains, isLoading: isLoadingDomains } = useFetchApi<
ReverseProxyDomain[]
>("/reverse-proxies/domains");
>("/reverse-proxies/domains", false, true, permission.services?.read);
const domainRequest = useApiCall<ReverseProxyDomain>(
"/reverse-proxies/domains",
true,
);
// Clusters
const { data: clusters, isLoading: isClustersLoading } = useFetchApi<
ReverseProxyCluster[]
>("/reverse-proxies/clusters", false, true, permission.services?.read);
const isSelfHostedCluster = useCallback(
(clusterAddress?: string) => {
if (!clusterAddress) return false;
return !!clusters?.find((c) => c.address === clusterAddress)?.self_hosted;
},
[clusters],
);
const [modalOpen, setModalOpen] = useState(false);
const [currentProxy, setCurrentProxy] = useState<ReverseProxy | undefined>();
const [initialTab, setInitialTab] = useState<string | undefined>();
@@ -483,6 +509,9 @@ export default function ReverseProxiesProvider({
createDomain,
validateDomain,
deleteDomain,
clusters,
isClustersLoading,
isSelfHostedCluster,
}}
>
{children}
@@ -604,7 +633,7 @@ function formatTargetDestination(
target: ReverseProxyTarget,
resolvedHost?: string,
): string {
const host = target.host || resolvedHost || "localhost";
const host = wrapIPv6(target.host || resolvedHost || "localhost");
const isDefault =
(target.protocol === "http" && target.port === 80) ||
(target.protocol === "https" && target.port === 443) ||
+3
View File
@@ -28,6 +28,9 @@ export interface Account {
auto_update_version: string;
auto_update_always: boolean;
local_auth_disabled?: boolean;
local_mfa_enabled?: boolean;
ipv6_enabled_groups?: string[];
network_range_v6?: string;
};
onboarding?: AccountOnboarding;
}
+1
View File
@@ -5,6 +5,7 @@ export interface Peer {
id?: string;
name: string;
ip: string;
ipv6?: string;
connected: boolean;
created_at?: Date;
last_seen: Date;
+19
View File
@@ -15,6 +15,7 @@ export interface ReverseProxy {
proxy_cluster?: string;
targets: ReverseProxyTarget[];
enabled: boolean;
terminated?: boolean;
pass_host_header?: boolean;
rewrite_redirects?: boolean;
auth?: ReverseProxyAuth;
@@ -162,6 +163,24 @@ export interface ReverseProxyEvent {
metadata?: Record<string, string>;
}
export interface ReverseProxyCluster {
id?: string;
address: string;
connected_proxies: number;
self_hosted: boolean;
}
export interface ReverseProxyClusterToken {
id?: string;
name: string;
plain_token?: string;
expires_at?: string;
expires_in?: number;
created_at?: string;
last_used?: string;
revoked?: boolean;
}
export function isL4Event(event: ReverseProxyEvent): boolean {
return (
event.protocol === EventProtocol.TCP ||
+1
View File
@@ -37,6 +37,7 @@ export default function Navigation({
return (
<div
data-navigation
className={cn(
"whitespace-nowrap md:border-r dark:border-zinc-700/40 bg-gray-50 dark:bg-nb-gray relative group/navigation transition-all",
hideOnMobile ? "hidden md:block" : "",
@@ -19,6 +19,7 @@ import { HelpTooltip } from "@components/HelpTooltip";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { useApiCall } from "@utils/api";
import { normalizeHostCIDR } from "@utils/ip";
import { useDialog } from "@/contexts/DialogProvider";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -172,7 +173,7 @@ export function ResourceModalContent({
const promise = create({
name,
description,
address,
address: normalizeHostCIDR(address),
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
@@ -196,7 +197,7 @@ export function ResourceModalContent({
const promise = update({
name,
description,
address,
address: normalizeHostCIDR(address),
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
@@ -58,7 +58,7 @@ export const ResourceSingleAddressInput = ({
// Case 2: If it's not a valid domain, check if it's a valid CIDR
if (!cidr.isValidAddress(value)) {
return "Please enter a valid IP or CIDR, e.g., 10.0.0.21, 192.168.1.0/24";
return "Please enter a valid IP or CIDR, e.g., 10.0.0.21, 192.168.1.0/24, 2001:db8::1 or 2001:db8::/64";
}
return ""; // Valid CIDR
@@ -34,7 +34,7 @@ export const NetworkRoutingPeersTabContent = ({
return {
...router,
search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`,
search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${peer?.ipv6 ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`,
};
});
}, [users, peers, routers, groups]);
@@ -2,6 +2,7 @@ import Button from "@components/Button";
import { notify } from "@components/Notification";
import { RadioCard, RadioCardGroup } from "@components/RadioCard";
import { useApiCall } from "@utils/api";
import { normalizeHostCIDR } from "@utils/ip";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
@@ -67,7 +68,7 @@ export const OnboardingAddResource = ({
{
name: resourceType === "subnet" ? "My Subnet" : "My Resource",
description: "Created during onboarding",
address: resourceAddress,
address: normalizeHostCIDR(resourceAddress),
enabled: true,
groups: [],
},
@@ -178,15 +179,15 @@ export const OnboardingAddResource = ({
const description = useMemo(() => {
if (resourceType === "ip")
return "Enter a single IPv4 address of your resource";
return "Enter a single IPv4 or IPv6 address of your resource";
if (resourceType === "subnet") return "Enter a CIDR range of your network";
if (resourceType === "domain")
return "Enter a domain name of your resource";
}, [resourceType]);
const placeholder = useMemo(() => {
if (resourceType === "ip") return "e.g., 192.168.31.45";
if (resourceType === "subnet") return "e.g., 192.168.1.0/24";
if (resourceType === "ip") return "e.g., 192.168.31.45 or 2001:db8::1";
if (resourceType === "subnet") return "e.g., 192.168.1.0/24 or 2001:db8::/64";
if (resourceType === "domain")
return "e.g., service.internal or *.services.internal";
}, [resourceType]);
@@ -211,13 +212,13 @@ export const OnboardingAddResource = ({
value={"ip"}
title={"Single IP Address"}
icon={<WorkflowIcon size={12} />}
description={"IPv4 address like 192.168.31.45"}
description={"IPv4 or IPv6 address like 192.168.31.45"}
/>
<RadioCard
value={"subnet"}
title={"Entire Subnet"}
icon={<NetworkIcon size={12} />}
description={"CIDR range like 192.168.0.0/24"}
description={"CIDR range like 192.168.0.0/24 or 2001:db8::/64"}
/>
<RadioCard
value={"domain"}
@@ -32,8 +32,9 @@ export const OnboardingTestResource = ({
const pingAddress = useMemo(() => {
let a = resource?.address || "";
if (isHost && a.endsWith("/32")) {
a = a.slice(0, -3);
if (isHost) {
if (a.endsWith("/32")) a = a.slice(0, -3);
else if (a.endsWith("/128")) a = a.slice(0, -4);
}
if (isWildCard) return `(any subdomain of ${a})`;
return isSubnet ? `(resource ip in your subnet)` : a;
+118
View File
@@ -0,0 +1,118 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import { Input } from "@components/Input";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import cidr from "ip-cidr";
import { trim } from "lodash";
import React, { useMemo, useState } from "react";
type IPVersion = "v4" | "v6";
interface PeerEditIPModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (ip: string) => void;
currentIP: string;
version: IPVersion;
}
const config: Record<
IPVersion,
{
title: string;
description: string;
placeholder: string;
errorMessage: string;
validate: (ip: string) => boolean;
}
> = {
v4: {
title: "Edit Peer IP Address",
description: "Update the NetBird IP address for this peer.",
placeholder: "e.g., 100.64.0.15",
errorMessage: "Please enter a valid IP, e.g., 100.64.0.15",
validate: (ip: string) =>
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
ip,
),
},
v6: {
title: "Edit Peer IPv6 Address",
description: "Update the NetBird IPv6 address for this peer.",
placeholder: "e.g., fd00:1234::1",
errorMessage: "Please enter a valid IPv6 address, e.g., fd00:1234::1",
validate: (ip: string) => cidr.isValidAddress(ip) && ip.includes(":"),
},
};
export function PeerEditIPModal({
open,
onOpenChange,
onSave,
currentIP,
version,
}: Readonly<PeerEditIPModalProps>) {
const { title, description, placeholder, errorMessage, validate } =
config[version];
const [ip, setIP] = useState(currentIP);
const isDisabled = useMemo(() => {
if (ip === currentIP) return true;
const trimmed = trim(ip);
return trimmed.length === 0 || !validate(trimmed);
}, [ip, currentIP, validate]);
const error = useMemo(() => {
if (ip === currentIP) return "";
if (!validate(trim(ip))) return errorMessage;
return "";
}, [ip, currentIP, validate, errorMessage]);
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"max-w-md"}>
<form>
<ModalHeader title={title} description={description} color={"blue"} />
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
placeholder={placeholder}
value={ip}
onChange={(e) => setIP(e.target.value)}
error={error}
/>
</div>
<Callout>Changes take effect when the peer reconnects.</Callout>
</div>
<ModalFooter className={"items-center"} separator={false}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => onSave(trim(ip))}
disabled={isDisabled}
>
Save
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
);
}
Loaded 20 of 43 files, more files were not shown because too many files have changed in this diff. Show more