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
26 changed files with 1088 additions and 36 deletions

No files matched your search

@@ -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>
);
}
+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;
+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) ||
+1
View File
@@ -28,6 +28,7 @@ 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;
};
+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
@@ -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;
@@ -4,6 +4,7 @@ import HelpText from "@components/HelpText";
import Button from "@components/Button";
import { Input } from "@components/Input";
import cidr from "ip-cidr";
import { isIPv6 } from "@utils/ip";
import {
FlagIcon,
MinusCircleIcon,
@@ -180,13 +181,17 @@ type Props = {
function validateRule(rule: AccessRule): string {
if (rule.type === "country" || !rule.value) return "";
if (rule.type === "ip") {
const val = rule.value.includes("/") ? rule.value : `${rule.value}/32`;
let val = rule.value;
if (!val.includes("/")) {
const suffix = isIPv6(val) ? 128 : 32;
val = `${val}/${suffix}`;
}
if (!cidr.isValidAddress(val)) {
return "Please enter a valid IP address, e.g., 85.203.15.42";
return "Please enter a valid IP address, e.g., 85.203.15.42 or 2001:db8::1";
}
} else {
if (!rule.value.includes("/") || !cidr.isValidAddress(rule.value)) {
return "Please enter a valid CIDR block, e.g., 74.125.0.0/16";
return "Please enter a valid CIDR block, e.g., 74.125.0.0/16 or 2001:db8::/64";
}
}
return "";
@@ -312,8 +317,8 @@ export const ReverseProxyAccessControlRules = ({
<Input
placeholder={
rule.type === "ip"
? "e.g., 85.203.15.42"
: "e.g., 74.125.0.0/16"
? "e.g., 85.203.15.42 or 2001:db8::1"
: "e.g., 74.125.0.0/16 or 2001:db8::/64"
}
value={rule.value}
onChange={(e) =>
@@ -10,6 +10,7 @@ import { useMemo } from "react";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyDomainType } from "@/interfaces/ReverseProxy";
import { isNetBirdHosted } from "@utils/netbird";
import TruncatedText from "@components/ui/TruncatedText";
interface DomainSelectorProps {
value: string;
@@ -25,7 +26,7 @@ export function CustomDomainSelector({
className,
}: DomainSelectorProps) {
const router = useRouter();
const { domains } = useReverseProxies();
const { domains, isSelfHostedCluster } = useReverseProxies();
const options: SelectOption[] = useMemo(() => {
const opts: SelectOption[] = [];
@@ -34,15 +35,20 @@ export function CustomDomainSelector({
domains
?.filter((d) => d.type === ReverseProxyDomainType.FREE)
.forEach((domain) => {
const isSelfHosted = isSelfHostedCluster(
domain?.target_cluster ?? domain?.domain,
);
opts.push({
value: domain.domain,
label: `.${domain.domain}`,
renderItem: () => (
<div className="flex items-center gap-2 w-full text-sm justify-between">
<div className="flex items-center gap-2">
<span>.{domain.domain}</span>
<TruncatedText text={`.${domain.domain}`} maxWidth={"260px"} />
</div>
{isNetBirdHosted() ? (
{isSelfHosted ? (
<SmallBadge text="Self-hosted" variant="sky" size="md" />
) : isNetBirdHosted() ? (
<SmallBadge text="Free" variant="green" size="md" />
) : (
<SmallBadge text="Cluster" variant="green" size="md" />
@@ -83,7 +89,7 @@ export function CustomDomainSelector({
});
return opts;
}, [domains]);
}, [domains, isSelfHostedCluster]);
const handleChange = (selectedValue: string) => {
if (selectedValue === "add_custom") {
@@ -98,7 +104,7 @@ export function CustomDomainSelector({
value={value}
onChange={handleChange}
options={options}
popoverWidth={335}
popoverWidth={380}
showSearch={true}
searchPlaceholder="Search domains..."
disabled={disabled}
@@ -146,7 +146,7 @@ export default function CustomDomainsTable({ headingTarget }: Readonly<Props>) {
<GetStartedTest
icon={
<SquareIcon
icon={<GlobeIcon className={"fill-nb-gray-200"} size={20} />}
icon={<GlobeIcon className={"text-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
@@ -1,5 +1,6 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import TruncatedText from "@components/ui/TruncatedText";
import { wrapIPv6 } from "@utils/ip";
import * as React from "react";
import {
isL4Event,
@@ -32,9 +33,10 @@ export const ReverseProxyEventsUrlCell = ({ event, service }: Props) => {
const isL4 = isL4Event(event);
const listenPort = service?.listen_port;
const wrappedHost = wrapIPv6(event.host || "");
const hostWithPort =
isL4 && listenPort ? `${event.host}:${listenPort}` : event.host || "-";
const fullUrl = isL4 ? hostWithPort : `${event.host}${event.path}`;
isL4 && listenPort ? `${wrappedHost}:${listenPort}` : wrappedHost || "-";
const fullUrl = isL4 ? hostWithPort : `${wrappedHost}${event.path}`;
return (
<TruncatedText
@@ -51,7 +53,7 @@ export const ReverseProxyEventsUrlCell = ({ event, service }: Props) => {
>
<CopyToClipboardText message={"URL has been copied to your clipboard"}>
<span className="font-mono text-[0.82rem] whitespace-nowrap">
<span className="text-nb-gray-200">{event.host}</span>
<span className="text-nb-gray-200">{wrappedHost}</span>
{isL4 && listenPort && (
<span className="text-nb-gray-300">:{listenPort}</span>
)}
@@ -0,0 +1,60 @@
import Button from "@components/Button";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesActionCell({
cluster,
}: Readonly<Props>) {
const { confirm } = useDialog();
const request = useApiCall<ReverseProxyCluster>("/reverse-proxies/clusters");
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const handleDelete = async () => {
const choice = await confirm({
title: `Delete '${cluster.address}'?`,
description:
"Are you sure you want to delete this proxy cluster? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
maxWidthClass: "max-w-md",
});
if (!choice) return;
notify({
title: cluster.address,
description: "Proxy cluster was successfully deleted",
promise: request
.del({}, `/${encodeURIComponent(cluster.address)}`)
.then(() => {
mutate("/reverse-proxies/clusters");
}),
loadingMessage: "Deleting the proxy cluster...",
});
};
return (
<div className={"flex justify-end pr-4"}>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={handleDelete}
disabled={!permission?.services?.delete}
>
<Trash2 size={16} />
Delete
</Button>
</div>
);
}
@@ -0,0 +1,25 @@
import Badge from "@components/Badge";
import { Server } from "lucide-react";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesConnectedCell({
cluster,
}: Readonly<Props>) {
const count = cluster.connected_proxies;
return (
<div className="flex items-center w-full">
<Badge variant={"gray"}>
<Server size={11} className={"relative -top-[0.5px]"} />
<div>
<span className="font-medium text-xs">
{count > 0 ? count : "No Proxies Connected"}
</span>
</div>
</Badge>
</div>
);
}
@@ -0,0 +1,339 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import { notify } from "@components/Notification";
import CardTable from "@components/CardTable";
import Code from "@components/Code";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import InlineLink from "@components/InlineLink";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import ModalHeader from "@components/modal/ModalHeader";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import {
ExternalLinkIcon,
GlobeIcon,
ListIcon,
Loader2,
ServerIcon,
SquareTerminalIcon,
} from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useApiCall } from "@/utils/api";
import { cn, validator } from "@utils/helpers";
import { GRPC_API_ORIGIN, isNetBirdHosted } from "@/utils/netbird";
import {
REVERSE_PROXY_CLUSTERS_DOCS_LINK,
ReverseProxyClusterToken,
} from "@/interfaces/ReverseProxy";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const SelfHostedProxiesModal = ({ open, onOpenChange }: Props) => {
const { mutate } = useSWRConfig();
const [tab, setTab] = useState("domain");
const [domain, setDomain] = useState("");
const [token, setToken] = useState("");
const [isGeneratingToken, setIsGeneratingToken] = useState(true);
const tokenRequest = useApiCall<ReverseProxyClusterToken>(
"/reverse-proxies/proxy-tokens",
);
const domainError = useMemo(() => {
if (!domain) return "";
const isValid = validator.isValidDomain(domain, {
allowWildcard: false,
allowOnlyTld: false,
preventLeadingAndTrailingDots: true,
});
if (!isValid) {
return "Please enter a valid TLD domain, e.g., company.com";
}
return "";
}, [domain]);
const managementUrl = isNetBirdHosted()
? "https://api.netbird.io"
: GRPC_API_ORIGIN || "";
const dockerCommand = `docker run -d \\
-v /var/lib/certs:/certs \\
-e NB_PROXY_CERTIFICATE_DIRECTORY=/certs \\
-e NB_PROXY_ALLOW_INSECURE=true \\
-e NB_PROXY_MANAGEMENT_ADDRESS=${managementUrl} \\
-e NB_PROXY_ACME_CERTIFICATES=true \\
-e NB_PROXY_DOMAIN=${domain} \\
-e NB_PROXY_LOG_LEVEL=info \\
-e NB_PROXY_TOKEN=${token || "<TOKEN>"} \\
-p 80:80 -p 443:443 \\
netbirdio/reverse-proxy:latest`;
const generateToken = useCallback(async () => {
setIsGeneratingToken(true);
const promise = tokenRequest
.post({
name: domain,
expires_in: 0,
})
.then((res) => {
setToken(res?.plain_token ?? "");
})
.finally(() => {
setIsGeneratingToken(false);
});
notify({
title: "Proxy Token",
description: "Failed to generate proxy token",
promise,
loadingMessage: "Generating proxy token...",
showOnlyError: true,
preventSuccessToast: true,
});
return promise;
}, [domain, tokenRequest]);
const goToInstall = useCallback(() => {
setTab("install");
if (!token) generateToken();
}, [token, generateToken]);
const finishSetup = () => {
onOpenChange(false);
mutate("/reverse-proxies/clusters");
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"relative max-w-[600px]"} showClose={true}>
<ModalHeader
icon={<ServerIcon size={16} />}
title={"Setup Proxy"}
description={"Setup a self-hosted reverse proxy"}
color={"netbird"}
/>
<Tabs
value={tab}
onValueChange={(v) => (v === "install" ? goToInstall() : setTab(v))}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"domain"}>
<GlobeIcon size={14} />
Domain
</TabsTrigger>
<TabsTrigger
value={"dns"}
disabled={!domain.trim() || !!domainError}
>
<ListIcon size={14} />
DNS Records
</TabsTrigger>
<TabsTrigger
value={"install"}
disabled={!domain.trim() || !!domainError}
>
<SquareTerminalIcon size={14} />
Run the Proxy
</TabsTrigger>
</TabsList>
<TabsContent value={"domain"} className={"pb-8"}>
<div className={"px-8 flex flex-col gap-6"}>
<div>
<Label>Domain</Label>
<HelpText>
Enter a domain name that will be used for your proxy.
</HelpText>
<Input
autoFocus={true}
placeholder={"e.g., proxy.company.com"}
value={domain}
error={domainError}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDomain(e.target.value)
}
/>
</div>
<Callout variant={"info"}>
In order to run the proxy, please make sure your machine meets
the following requirements:
<ul className={"list-disc pl-4 mt-2 flex flex-col gap-1"}>
<li>
<span className={"text-white font-medium"}>
Publicly accessible IP address
</span>
</li>
<li>
<span className={"text-white font-medium"}>Docker</span>{" "}
installed and running
</li>
<li>
<span className={"text-white font-medium"}>
Port 80 and 443
</span>{" "}
open and not in use
</li>
</ul>
</Callout>
</div>
</TabsContent>
<TabsContent value={"dns"} className={"pb-8"}>
<div className={"px-8 flex flex-col"}>
<div>
<Label>Configure DNS</Label>
<HelpText>
Add the following DNS records pointing to your machine&apos;s
public IP address.
</HelpText>
</div>
<CardTable>
<CardTable.Header>
<CardTable.HeaderCell width={120}>Type</CardTable.HeaderCell>
<CardTable.HeaderCell>Name</CardTable.HeaderCell>
<CardTable.HeaderCell>Content</CardTable.HeaderCell>
</CardTable.Header>
<CardTable.Body>
<CardTable.Row>
<CardTable.Cell>A Record</CardTable.Cell>
<CardTable.Cell copy copyText={domain}>
{domain}
</CardTable.Cell>
<CardTable.Cell className={"italic"}>
Your machine&apos;s IP
</CardTable.Cell>
</CardTable.Row>
<CardTable.Row>
<CardTable.Cell>A Record</CardTable.Cell>
<CardTable.Cell copy copyText={`*.${domain}`}>
{`*.${domain}`}
</CardTable.Cell>
<CardTable.Cell className={"italic"}>
Your machine&apos;s IP
</CardTable.Cell>
</CardTable.Row>
</CardTable.Body>
</CardTable>
</div>
</TabsContent>
<TabsContent value={"install"} className={"pb-8"}>
<div className={"px-8 flex flex-col"}>
<div>
<Label>Run the Proxy with Docker</Label>
<HelpText>
Run the following command on your machine to start the proxy.
</HelpText>
</div>
<Code
codeToCopy={dockerCommand}
className={cn(
"overflow-hidden",
isGeneratingToken && "!border-nb-gray-930",
)}
showCopyIcon={!isGeneratingToken}
>
{isGeneratingToken && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 text-nb-gray-100 bg-nb-gray-950/90">
<Loader2 size={16} className="animate-spin" />
Generating proxy token...
</div>
)}
<Code.Line>docker run -d \</Code.Line>
<Code.Line> -v /var/lib/certs:/certs \</Code.Line>
<Code.Line>
{" "}
-e NB_PROXY_CERTIFICATE_DIRECTORY=/certs \
</Code.Line>
<Code.Line> -e NB_PROXY_ALLOW_INSECURE=true \</Code.Line>
<Code.Line>
{" "}
-e NB_PROXY_MANAGEMENT_ADDRESS=
<span className={"text-netbird"}>{managementUrl}</span> \
</Code.Line>
<Code.Line> -e NB_PROXY_ACME_CERTIFICATES=true \</Code.Line>
<Code.Line>
{" "}
-e NB_PROXY_DOMAIN=
<span className={"text-netbird"}>{domain}</span> \
</Code.Line>
<Code.Line> -e NB_PROXY_LOG_LEVEL=info \</Code.Line>
<Code.Line>
{" "}
-e NB_PROXY_TOKEN=
<span className={"text-netbird"}>{token || "<TOKEN>"}</span> \
</Code.Line>
<Code.Line> -p 80:80 -p 443:443 \</Code.Line>
<Code.Line> netbirdio/reverse-proxy:latest</Code.Line>
</Code>
</div>
</TabsContent>
</Tabs>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Self-Hosted Proxies
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
{tab === "domain" && (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab("dns")}
disabled={!domain.trim() || !!domainError}
>
Continue
</Button>
</>
)}
{tab === "dns" && (
<>
<Button variant={"secondary"} onClick={() => setTab("domain")}>
Back
</Button>
<Button variant={"primary"} onClick={goToInstall}>
Continue
</Button>
</>
)}
{tab === "install" && (
<>
<Button variant={"secondary"} onClick={() => setTab("dns")}>
Back
</Button>
<Button variant={"primary"} onClick={finishSetup}>
Finish Setup
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,20 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import CircleIcon from "@/assets/icons/CircleIcon";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesNameCell({ cluster }: Readonly<Props>) {
return (
<div className="flex items-center gap-2.5 ml-2">
<CircleIcon active={true} size={8} inactiveDot={"gray"} />
<CopyToClipboardText
message={`${cluster.address} has been copied to clipboard`}
>
<span className="font-medium">{cluster.address}</span>
</CopyToClipboardText>
</div>
);
}
@@ -0,0 +1,165 @@
import Button from "@components/Button";
import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { PlusCircle, ServerIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
import useFetchApi from "@/utils/api";
import SelfHostedProxiesActionCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesActionCell";
import SelfHostedProxiesConnectedCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesConnectedCell";
import { SelfHostedProxiesModal } from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesModal";
import SelfHostedProxiesNameCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesNameCell";
const ClustersColumns: ColumnDef<ReverseProxyCluster>[] = [
{
accessorKey: "address",
header: ({ column }) => {
return <DataTableHeader column={column}>Proxy Cluster</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <SelfHostedProxiesNameCell cluster={row.original} />,
},
{
accessorKey: "connected_proxies",
header: ({ column }) => {
return (
<DataTableHeader column={column}>Connected Proxies</DataTableHeader>
);
},
cell: ({ row }) => (
<SelfHostedProxiesConnectedCell cluster={row.original} />
),
},
{
id: "searchString",
accessorFn: (row) => row.address,
},
{
id: "actions",
accessorKey: "address",
header: "",
cell: ({ row }) => <SelfHostedProxiesActionCell cluster={row.original} />,
},
];
type Props = {
headingTarget?: HTMLHeadingElement | null;
};
export default function SelfHostedProxiesTable({
headingTarget,
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const path = usePathname();
const { permission } = usePermissions();
const { data: clusters, isLoading } = useFetchApi<ReverseProxyCluster[]>(
"/reverse-proxies/clusters",
);
const selfHostedClusters = useMemo(() => {
return clusters?.filter((c) => c.self_hosted) ?? [];
}, [clusters]);
const [addModalOpen, setAddModalOpen] = useState(false);
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "address",
desc: false,
},
],
);
return (
<>
<SelfHostedProxiesModal
open={addModalOpen}
onOpenChange={setAddModalOpen}
key={addModalOpen ? 1 : 0}
/>
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
inset={false}
keepStateInLocalStorage={false}
text={"Self-Hosted Proxies"}
sorting={sorting}
setSorting={setSorting}
columns={ClustersColumns}
data={selfHostedClusters}
useRowId={true}
searchPlaceholder={"Search by proxy cluster domain..."}
columnVisibility={{ searchString: false }}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<ServerIcon className={"text-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"Setup Your Own Self-Hosted Proxy Cluster"}
description={
"Setup self-hosted proxies on your own infrastructure for full control over traffic and geographic location."
}
button={
<Button
variant={"primary"}
onClick={() => setAddModalOpen(true)}
disabled={!permission?.services?.create}
>
<PlusCircle size={16} />
Setup Proxy
</Button>
}
/>
}
rightSide={() => (
<>
{selfHostedClusters.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
onClick={() => setAddModalOpen(true)}
disabled={!permission?.services?.create}
>
<PlusCircle size={16} />
Setup Proxy
</Button>
)}
</>
)}
>
{(table) => (
<>
<DataTableRowsPerPage
table={table}
disabled={selfHostedClusters.length === 0}
/>
<DataTableRefreshButton
isDisabled={selfHostedClusters.length === 0}
onClick={() => {
mutate("/reverse-proxies/clusters").then();
}}
/>
</>
)}
</DataTable>
</>
);
}
Loaded 20 of 26 files, more files were not shown because too many files have changed in this diff. Show more