mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fab9d7a83 |
No files matched your search
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Linux tab of the setup modal — distro selector spec.
|
||||
*
|
||||
* The manual-install accordion offers per-distro instructions via a
|
||||
* SelectDropdown (data-testid="linux-distro-select"). This validates that
|
||||
* switching distros swaps the repository and install commands, that
|
||||
* CLI-only distros render no desktop-app line, and that the copy button
|
||||
* emits commands without comment lines. Runs against /install, which
|
||||
* renders the setup modal without authentication.
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
async function openLinuxManualInstall(page: import("@playwright/test").Page) {
|
||||
await page.goto("/install");
|
||||
await page.getByTestId("setup-netbird-modal").waitFor();
|
||||
await page.getByRole("tab", { name: /linux/i }).click();
|
||||
await page.getByText("Install manually with a package manager").click();
|
||||
await page.getByTestId("linux-distro-select").waitFor();
|
||||
}
|
||||
|
||||
async function selectDistro(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
await page.getByTestId("linux-distro-select").click();
|
||||
await page.getByRole("option", { name }).click();
|
||||
}
|
||||
|
||||
test.describe("Setup modal Linux distro instructions", () => {
|
||||
test("defaults to APT with repository, install and run steps", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openLinuxManualInstall(page);
|
||||
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
await expect(modal.getByTestId("linux-distro-select")).toContainText(
|
||||
"Debian / Ubuntu (APT)",
|
||||
);
|
||||
await expect(modal).toContainText(
|
||||
"https://pkgs.netbird.io/debian stable main",
|
||||
);
|
||||
await expect(modal).toContainText("sudo apt-get install netbird");
|
||||
await expect(modal).toContainText(
|
||||
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
|
||||
);
|
||||
// The run step stays present alongside the distro-specific steps
|
||||
await expect(modal.getByText("netbird up")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("switches repository and install commands per distro", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openLinuxManualInstall(page);
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
|
||||
await selectDistro(page, "Fedora (DNF)");
|
||||
await expect(modal).toContainText("/etc/yum.repos.d/netbird.repo");
|
||||
await expect(modal).toContainText("sudo dnf install netbird");
|
||||
await expect(modal).toContainText(
|
||||
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
|
||||
);
|
||||
await expect(modal).not.toContainText("apt-get");
|
||||
await expect(modal).not.toContainText("epel-release");
|
||||
|
||||
await selectDistro(page, "RHEL / AlmaLinux / Rocky (DNF)");
|
||||
await expect(modal).toContainText("sudo dnf install epel-release -y");
|
||||
await expect(modal).toContainText(
|
||||
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
|
||||
);
|
||||
});
|
||||
|
||||
test("offers CLI only on Amazon Linux", async ({ page }) => {
|
||||
await openLinuxManualInstall(page);
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
|
||||
await selectDistro(page, "Amazon Linux (YUM)");
|
||||
await expect(modal).toContainText("sudo yum install netbird");
|
||||
await expect(modal).not.toContainText("netbird-ui");
|
||||
await expect(modal).not.toContainText("for CLI only");
|
||||
await expect(modal).toContainText("only the CLI is available");
|
||||
});
|
||||
|
||||
test("copies install commands without comment lines", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await openLinuxManualInstall(page);
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
|
||||
// Copy buttons in DOM order: install script, run command, repository,
|
||||
// install step, run command (accordion).
|
||||
await modal.getByTestId("copy-to-clipboard").nth(3).click();
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied, "clipboard should hold runnable commands only").toBe(
|
||||
[
|
||||
"sudo apt-get update",
|
||||
"sudo apt-get install netbird",
|
||||
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -33,21 +33,12 @@ export const CrowdSecMode = {
|
||||
|
||||
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
|
||||
|
||||
export const AppSecMode = {
|
||||
OFF: "off",
|
||||
ENFORCE: "enforce",
|
||||
OBSERVE: "observe",
|
||||
} as const;
|
||||
|
||||
export type AppSecMode = (typeof AppSecMode)[keyof typeof AppSecMode];
|
||||
|
||||
export interface AccessRestrictions {
|
||||
allowed_cidrs?: string[];
|
||||
blocked_cidrs?: string[];
|
||||
allowed_countries?: string[];
|
||||
blocked_countries?: string[];
|
||||
crowdsec_mode?: CrowdSecMode;
|
||||
appsec_mode?: AppSecMode;
|
||||
}
|
||||
|
||||
export interface ReverseProxyMeta {
|
||||
@@ -131,7 +122,6 @@ export interface ReverseProxyDomain {
|
||||
supports_custom_ports?: boolean;
|
||||
require_subdomain?: boolean;
|
||||
supports_crowdsec?: boolean;
|
||||
supports_appsec?: boolean;
|
||||
supports_private?: boolean;
|
||||
}
|
||||
|
||||
@@ -198,7 +188,6 @@ export interface ReverseProxyCluster {
|
||||
supports_custom_ports?: boolean;
|
||||
require_subdomain?: boolean;
|
||||
supports_crowdsec?: boolean;
|
||||
supports_appsec?: boolean;
|
||||
// True when at least one connected proxy in this cluster is running embedded
|
||||
// in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel.
|
||||
// Lets the dashboard distinguish per-peer / private clusters from centralised
|
||||
|
||||
@@ -19,13 +19,8 @@ import {
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { CountrySelector } from "@/components/ui/CountrySelector";
|
||||
import {
|
||||
AccessRestrictions,
|
||||
AppSecMode,
|
||||
CrowdSecMode,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
|
||||
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
|
||||
import { ReverseProxyAppSecInspection } from "@/modules/reverse-proxy/ReverseProxyAppSecInspection";
|
||||
|
||||
type AccessAction = "allow" | "block";
|
||||
type AccessRuleType = "country" | "ip" | "cidr";
|
||||
@@ -126,7 +121,6 @@ function restrictionsToRules(
|
||||
function rulesToRestrictions(
|
||||
rules: AccessRule[],
|
||||
crowdsecMode?: CrowdSecMode,
|
||||
appsecMode?: AppSecMode,
|
||||
): AccessRestrictions | undefined {
|
||||
const allowed_countries: string[] = [];
|
||||
const blocked_countries: string[] = [];
|
||||
@@ -150,14 +144,12 @@ function rulesToRestrictions(
|
||||
}
|
||||
|
||||
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
|
||||
const hasAppSec = appsecMode != null && appsecMode !== AppSecMode.OFF;
|
||||
const hasAny =
|
||||
allowed_countries.length > 0 ||
|
||||
blocked_countries.length > 0 ||
|
||||
allowed_cidrs.length > 0 ||
|
||||
blocked_cidrs.length > 0 ||
|
||||
hasCrowdSec ||
|
||||
hasAppSec;
|
||||
hasCrowdSec;
|
||||
|
||||
if (!hasAny) return undefined;
|
||||
|
||||
@@ -167,7 +159,6 @@ function rulesToRestrictions(
|
||||
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
|
||||
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
|
||||
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
|
||||
...(hasAppSec && { appsec_mode: appsecMode }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,7 +167,6 @@ type Props = {
|
||||
onChange: (value: AccessRestrictions | undefined) => void;
|
||||
onValidationChange?: (hasErrors: boolean) => void;
|
||||
supportsCrowdSec?: boolean;
|
||||
supportsAppSec?: boolean;
|
||||
};
|
||||
|
||||
function validateRule(rule: AccessRule): string {
|
||||
@@ -203,7 +193,6 @@ export const ReverseProxyAccessControlRules = ({
|
||||
onChange,
|
||||
onValidationChange,
|
||||
supportsCrowdSec,
|
||||
supportsAppSec,
|
||||
}: Props) => {
|
||||
const [rules, dispatch] = useReducer(
|
||||
rulesReducer,
|
||||
@@ -211,10 +200,6 @@ export const ReverseProxyAccessControlRules = ({
|
||||
restrictionsToRules,
|
||||
);
|
||||
|
||||
const [appsecMode, setAppsecMode] = useState<AppSecMode>(
|
||||
value?.appsec_mode ?? AppSecMode.OFF,
|
||||
);
|
||||
|
||||
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
|
||||
value?.crowdsec_mode ?? CrowdSecMode.OFF,
|
||||
);
|
||||
@@ -242,14 +227,8 @@ export const ReverseProxyAccessControlRules = ({
|
||||
}, [supportsCrowdSec]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsAppSec) {
|
||||
setAppsecMode(AppSecMode.OFF);
|
||||
}
|
||||
}, [supportsAppSec]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, appsecMode));
|
||||
}, [rules, crowdsecMode, appsecMode]);
|
||||
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
|
||||
}, [rules, crowdsecMode]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current?.(hasErrors);
|
||||
@@ -263,12 +242,6 @@ export const ReverseProxyAccessControlRules = ({
|
||||
onChange={setCrowdsecMode}
|
||||
/>
|
||||
)}
|
||||
{supportsAppSec && (
|
||||
<ReverseProxyAppSecInspection
|
||||
value={appsecMode}
|
||||
onChange={setAppsecMode}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Label>Access Control Rules</Label>
|
||||
<HelpText>
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { EyeIcon, PowerOffIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import { AppSecMode } from "@/interfaces/ReverseProxy";
|
||||
import Image from "next/image";
|
||||
import CrowdSecIconImage from "@/assets/integrations/crowdsec.png";
|
||||
|
||||
type Props = {
|
||||
value: AppSecMode;
|
||||
onChange: (value: AppSecMode) => void;
|
||||
};
|
||||
|
||||
type AppSecOption = {
|
||||
label: string;
|
||||
description?: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const APPSEC_OPTIONS: Record<AppSecMode, AppSecOption> = {
|
||||
[AppSecMode.OFF]: {
|
||||
label: "Disabled",
|
||||
icon: <PowerOffIcon size={14} />,
|
||||
},
|
||||
[AppSecMode.ENFORCE]: {
|
||||
label: "Enforce",
|
||||
description:
|
||||
"Flagged requests are blocked with 403. Every request is inspected before it reaches the service, and requests are denied if the engine is unreachable (fail-closed).",
|
||||
icon: <ShieldCheckIcon size={14} />,
|
||||
},
|
||||
[AppSecMode.OBSERVE]: {
|
||||
label: "Observe",
|
||||
description:
|
||||
"Flagged requests are logged but still forwarded. Use this to evaluate the rules against real traffic before enforcing.",
|
||||
icon: <EyeIcon size={14} />,
|
||||
},
|
||||
};
|
||||
|
||||
export const ReverseProxyAppSecInspection = ({ value, onChange }: Props) => {
|
||||
const selected = APPSEC_OPTIONS[value];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0 justify-between mb-6">
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={
|
||||
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70 shrink-0 relative"
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={CrowdSecIconImage}
|
||||
alt={"CrowdSec"}
|
||||
className={"rounded-[4px]"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>CrowdSec AppSec (WAF)</Label>
|
||||
<HelpText>
|
||||
Inspect HTTP requests for exploits.{" "}
|
||||
<b className={"text-white"}>Enforce</b> to block them or{" "}
|
||||
<b className={"text-white"}>Observe</b> to only log without
|
||||
blocking.
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={value} onValueChange={(v) => onChange(v as AppSecMode)}>
|
||||
<SelectTrigger className="w-[260px]" data-testid="appsec-mode-trigger">
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
{selected.icon}
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(APPSEC_OPTIONS).map(([mode, config]) => (
|
||||
<SelectItem
|
||||
key={mode}
|
||||
value={mode}
|
||||
data-testid={`appsec-mode-${mode}`}
|
||||
extra={
|
||||
config.description ? (
|
||||
<HelpTooltip
|
||||
triggerClassName="ml-[0.01rem]"
|
||||
align="center"
|
||||
side="right"
|
||||
content={<>{config.description}</>}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span className="whitespace-nowrap">{config.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -814,7 +814,6 @@ export default function ReverseProxyModal({
|
||||
onChange={setAccessRestrictions}
|
||||
onValidationChange={setAccessControlHasErrors}
|
||||
supportsCrowdSec={selectedDomain?.supports_crowdsec}
|
||||
supportsAppSec={!isL4Mode && selectedDomain?.supports_appsec}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -47,15 +47,6 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
|
||||
icon: <ShieldAlert size={14} className={"text-green-500"} />,
|
||||
});
|
||||
}
|
||||
if (cluster.supports_appsec) {
|
||||
features.push({
|
||||
key: "appsec",
|
||||
label: "AppSec (WAF)",
|
||||
description:
|
||||
"Cluster has a CrowdSec AppSec endpoint configured across all active proxies, so services can inspect HTTP requests for exploits.",
|
||||
icon: <ShieldAlert size={14} className={"text-green-500"} />,
|
||||
});
|
||||
}
|
||||
if (cluster.private) {
|
||||
features.push({
|
||||
key: "private",
|
||||
@@ -65,7 +56,8 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
|
||||
Lets you publish services that are only reachable from peers in your
|
||||
NetBird network. Required for{" "}
|
||||
<span className={"font-medium text-white"}>NetBird-Only Access</span>{" "}
|
||||
and <span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
|
||||
and{" "}
|
||||
<span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
|
||||
target types.
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -111,21 +111,6 @@ export const ReverseProxyEventsAuthMethodCell = ({
|
||||
icon: <ShieldOff size={12} />,
|
||||
label: "CrowdSec Unavailable",
|
||||
};
|
||||
case "appsec_ban":
|
||||
return {
|
||||
icon: <ShieldAlert size={12} />,
|
||||
label: "AppSec Ban",
|
||||
};
|
||||
case "appsec_captcha":
|
||||
return {
|
||||
icon: <ShieldAlert size={12} />,
|
||||
label: "AppSec Captcha",
|
||||
};
|
||||
case "appsec_unavailable":
|
||||
return {
|
||||
icon: <ShieldOff size={12} />,
|
||||
label: "AppSec Unavailable",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: null,
|
||||
|
||||
@@ -9,9 +9,6 @@ const VERDICT_LABELS: Record<string, string> = {
|
||||
crowdsec_ban: "Ban",
|
||||
crowdsec_captcha: "Captcha",
|
||||
crowdsec_throttle: "Throttle",
|
||||
appsec_ban: "Ban",
|
||||
appsec_captcha: "Captcha",
|
||||
appsec_unavailable: "Unavailable",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -20,16 +17,12 @@ type Props = {
|
||||
|
||||
export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
|
||||
const metadata = event.metadata;
|
||||
// Only one of the two can be the reason a request was flagged without being
|
||||
// blocked, since an enforced block sets auth_method_used instead.
|
||||
const source = metadata?.appsec_verdict ? "appsec" : "crowdsec";
|
||||
const verdictKey = `${source}_verdict`;
|
||||
const verdict = metadata?.[verdictKey];
|
||||
const verdict = metadata?.crowdsec_verdict;
|
||||
|
||||
if (verdict && !event.auth_method_used?.startsWith(`${source}_`)) {
|
||||
if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) {
|
||||
const verdictLabel = VERDICT_LABELS[verdict] ?? verdict;
|
||||
const metaEntries = Object.entries(metadata!).filter(
|
||||
([k]) => k !== verdictKey,
|
||||
([k]) => k !== "crowdsec_verdict",
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -56,8 +49,7 @@ export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
|
||||
<div className="px-3 py-2">
|
||||
<Badge variant="gray" className="gap-1.5">
|
||||
<ShieldAlert size={12} className="text-yellow-500" />
|
||||
{source === "appsec" ? "AppSec" : "CrowdSec"} Observe:{" "}
|
||||
{verdictLabel}
|
||||
CrowdSec Observe: {verdictLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useMemo } from "react";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { AppSecMode, CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
|
||||
type RuleEntry = {
|
||||
key: string;
|
||||
@@ -44,35 +44,21 @@ export default function ReverseProxyAccessControlCell({
|
||||
const canConfigure = !!permission?.services?.update;
|
||||
const restrictions = reverseProxy.access_restrictions;
|
||||
|
||||
// Services on a custom domain carry its target_cluster in proxy_cluster, so
|
||||
// matching on domain alone misses them and reports no capability.
|
||||
const clusterDomain = domains?.find(
|
||||
(d) =>
|
||||
d.domain === reverseProxy.proxy_cluster ||
|
||||
d.target_cluster === reverseProxy.proxy_cluster,
|
||||
);
|
||||
|
||||
const supportsCrowdSec = clusterDomain?.supports_crowdsec;
|
||||
const supportsCrowdSec = domains?.find(
|
||||
(d) => d.domain === reverseProxy.proxy_cluster,
|
||||
)?.supports_crowdsec;
|
||||
|
||||
const hasCrowdSec =
|
||||
supportsCrowdSec &&
|
||||
restrictions?.crowdsec_mode != null &&
|
||||
restrictions.crowdsec_mode !== CrowdSecMode.OFF;
|
||||
|
||||
const supportsAppSec = clusterDomain?.supports_appsec;
|
||||
|
||||
const hasAppSec =
|
||||
supportsAppSec &&
|
||||
restrictions?.appsec_mode != null &&
|
||||
restrictions.appsec_mode !== AppSecMode.OFF;
|
||||
|
||||
const ruleCount =
|
||||
(restrictions?.allowed_cidrs?.length ?? 0) +
|
||||
(restrictions?.blocked_cidrs?.length ?? 0) +
|
||||
(restrictions?.allowed_countries?.length ?? 0) +
|
||||
(restrictions?.blocked_countries?.length ?? 0) +
|
||||
(hasCrowdSec ? 1 : 0) +
|
||||
(hasAppSec ? 1 : 0);
|
||||
(hasCrowdSec ? 1 : 0);
|
||||
|
||||
const rulesBadge = (
|
||||
<Badge
|
||||
@@ -177,20 +163,8 @@ export default function ReverseProxyAccessControlCell({
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAppSec) {
|
||||
entries.push({
|
||||
key: "appsec",
|
||||
label: "AppSec (WAF)",
|
||||
Icon: ShieldAlert,
|
||||
value:
|
||||
restrictions?.appsec_mode === AppSecMode.ENFORCE
|
||||
? "Enforce"
|
||||
: "Observe",
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [restrictions, countries, hasCrowdSec, hasAppSec]);
|
||||
}, [restrictions, countries, hasCrowdSec]);
|
||||
|
||||
const showRulesHover = ruleGroups.length > 0;
|
||||
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@components/Accordion";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Code from "@components/Code";
|
||||
import { SelectDropdown } from "@components/select/SelectDropdown";
|
||||
import Separator from "@components/Separator";
|
||||
import Steps from "@components/Steps";
|
||||
import TabsContentPadding, { TabsContent } from "@components/Tabs";
|
||||
import { IconBrandUbuntu } from "@tabler/icons-react";
|
||||
import { TerminalSquareIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { PackageIcon, TerminalSquareIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import {
|
||||
NetBirdUpCommand,
|
||||
@@ -25,6 +26,84 @@ type Props = {
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
type Distro = {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Commands that register the NetBird package repository. */
|
||||
repository: string[];
|
||||
/** Commands that run before the install lines, after the repository is added. */
|
||||
beforeInstall?: string[];
|
||||
cli: string;
|
||||
/**
|
||||
* Desktop app install lines. Omitted when the distribution cannot run it,
|
||||
* in which case only the CLI is offered.
|
||||
*/
|
||||
desktopApp?: string[];
|
||||
note: string;
|
||||
};
|
||||
|
||||
const YUM_REPOSITORY = [
|
||||
"sudo tee /etc/yum.repos.d/netbird.repo <<EOF",
|
||||
"[netbird]",
|
||||
"name=netbird",
|
||||
"baseurl=https://pkgs.netbird.io/yum/",
|
||||
"enabled=1",
|
||||
"gpgcheck=1",
|
||||
"gpgkey=https://pkgs.netbird.io/yum/repodata/repomd.xml.key",
|
||||
"repo_gpgcheck=1",
|
||||
"EOF",
|
||||
];
|
||||
|
||||
// The desktop app links GTK 4.10+ and WebKitGTK 6.0, and the released
|
||||
// packages do not declare those as dependencies, so each install line names
|
||||
// them explicitly. Distributions below that floor get the CLI only.
|
||||
const DISTROS: Distro[] = [
|
||||
{
|
||||
label: "Debian / Ubuntu (APT)",
|
||||
value: "apt",
|
||||
repository: [
|
||||
"sudo apt-get update",
|
||||
"sudo apt-get install ca-certificates curl gnupg -y",
|
||||
"curl -sSL https://pkgs.netbird.io/debian/public.key | sudo gpg --dearmor --output /usr/share/keyrings/netbird-archive-keyring.gpg",
|
||||
`echo 'deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main' | sudo tee /etc/apt/sources.list.d/netbird.list`,
|
||||
],
|
||||
beforeInstall: ["sudo apt-get update"],
|
||||
cli: "sudo apt-get install netbird",
|
||||
desktopApp: [
|
||||
"sudo apt-get install netbird-ui libgtk-4-1 libwebkitgtk-6.0-4 xdg-utils",
|
||||
],
|
||||
note: "The desktop app needs Ubuntu 24.04 or Debian 13 and newer. On earlier releases install the CLI only.",
|
||||
},
|
||||
{
|
||||
label: "Fedora (DNF)",
|
||||
value: "fedora",
|
||||
repository: YUM_REPOSITORY,
|
||||
cli: "sudo dnf install netbird",
|
||||
desktopApp: ["sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils"],
|
||||
note: "The desktop app needs Fedora 43 and newer. On earlier releases install the CLI only.",
|
||||
},
|
||||
{
|
||||
label: "RHEL / AlmaLinux / Rocky (DNF)",
|
||||
value: "rhel",
|
||||
repository: YUM_REPOSITORY,
|
||||
cli: "sudo dnf install netbird",
|
||||
// WebKitGTK 6.0 ships in EPEL rather than the base repositories, so the
|
||||
// desktop app path enables it first. CLI-only users do not need it.
|
||||
desktopApp: [
|
||||
"sudo dnf install epel-release -y",
|
||||
"sudo dnf install netbird-ui gtk4 webkitgtk6.0 xdg-utils",
|
||||
],
|
||||
note: "The desktop app needs version 10 or newer with EPEL enabled, which provides WebKitGTK 6.0. On version 9 install the CLI only.",
|
||||
},
|
||||
{
|
||||
label: "Amazon Linux (YUM)",
|
||||
value: "amazon",
|
||||
repository: YUM_REPOSITORY,
|
||||
cli: "sudo yum install netbird",
|
||||
note: "Amazon Linux does not ship GTK 4 or WebKitGTK 6.0, so only the CLI is available.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function LinuxTab({
|
||||
setupKey,
|
||||
setupKeyContent,
|
||||
@@ -32,8 +111,20 @@ export default function LinuxTab({
|
||||
showSetupKeyInfo = false,
|
||||
hostname,
|
||||
}: Readonly<Props>) {
|
||||
const [distroValue, setDistroValue] = useState(DISTROS[0].value);
|
||||
const distro =
|
||||
DISTROS.find((option) => option.value === distroValue) ?? DISTROS[0];
|
||||
|
||||
const runStep = setupKeyContent ? 3 : 2;
|
||||
const usingSetupKey = !!setupKey || !!setupKeyPlaceholder;
|
||||
|
||||
const hasDesktopApp = !!distro.desktopApp?.length;
|
||||
const installLines = [
|
||||
...(distro.beforeInstall ?? []),
|
||||
distro.cli,
|
||||
...(distro.desktopApp ?? []),
|
||||
];
|
||||
|
||||
return (
|
||||
<TabsContent value={String(OperatingSystem.LINUX)}>
|
||||
<TabsContentPadding>
|
||||
@@ -66,43 +157,56 @@ export default function LinuxTab({
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>
|
||||
<IconBrandUbuntu size={16} />
|
||||
Install manually on Ubuntu
|
||||
<PackageIcon size={16} />
|
||||
Install manually with a package manager
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className={"mt-1"}>
|
||||
<SelectDropdown
|
||||
value={distroValue}
|
||||
className={"w-[280px]"}
|
||||
onChange={setDistroValue}
|
||||
placeholder={"Select distribution"}
|
||||
options={DISTROS.map(({ label, value }) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
data-testid={"linux-distro-select"}
|
||||
/>
|
||||
</div>
|
||||
<Steps>
|
||||
<Steps.Step step={1}>
|
||||
<p>Add our repository</p>
|
||||
<Code>
|
||||
<Code.Line>sudo apt-get update</Code.Line>
|
||||
<Code.Line>
|
||||
sudo apt install ca-certificates curl gnupg -y
|
||||
</Code.Line>
|
||||
<Code.Line>
|
||||
curl -sSL https://pkgs.netbird.io/debian/public.key | sudo
|
||||
gpg --dearmor --output
|
||||
/usr/share/keyrings/netbird-archive-keyring.gpg
|
||||
</Code.Line>
|
||||
<Code.Line>
|
||||
{`echo 'deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main' | sudo tee /etc/apt/sources.list.d/netbird.list`}
|
||||
</Code.Line>
|
||||
<Code codeToCopy={distro.repository.join("\n")}>
|
||||
{distro.repository.map((line) => (
|
||||
<Code.Line key={line}>{line}</Code.Line>
|
||||
))}
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2}>
|
||||
<p>Install NetBird</p>
|
||||
<Code
|
||||
codeToCopy={[
|
||||
`sudo apt-get update`,
|
||||
`sudo apt-get install netbird`,
|
||||
`sudo apt-get install netbird-ui`,
|
||||
].join("\n")}
|
||||
>
|
||||
<Code.Line>sudo apt-get update</Code.Line>
|
||||
<Code.Comment># for CLI only</Code.Comment>
|
||||
<Code.Line>sudo apt-get install netbird</Code.Line>
|
||||
<Code.Comment># for GUI package</Code.Comment>
|
||||
<Code.Line>sudo apt-get install netbird-ui</Code.Line>
|
||||
<Code codeToCopy={installLines.join("\n")}>
|
||||
{distro.beforeInstall?.map((line) => (
|
||||
<Code.Line key={line}>{line}</Code.Line>
|
||||
))}
|
||||
{hasDesktopApp && (
|
||||
<Code.Comment># for CLI only</Code.Comment>
|
||||
)}
|
||||
<Code.Line>{distro.cli}</Code.Line>
|
||||
{hasDesktopApp && (
|
||||
<>
|
||||
<Code.Comment># for the desktop app</Code.Comment>
|
||||
{distro.desktopApp?.map((line) => (
|
||||
<Code.Line key={line}>{line}</Code.Line>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Code>
|
||||
<Callout variant={"info"} className={"mt-1"}>
|
||||
{distro.note}
|
||||
{hasDesktopApp &&
|
||||
" Desktop app packages are available for x86_64 only."}
|
||||
</Callout>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p>
|
||||
|
||||
Reference in new issue
Block a user