Compare commits

..
Author SHA1 Message Date
pascal e2ed029fa8 disable billing 2026-09-11 18:25:05 +02:00
pascal e8ee2d1093 add certificate posturecheck support 2026-09-04 20:17:51 +02:00
21 changed files with 549 additions and 169 deletions

No files matched your search

+2 -7
View File
@@ -70,7 +70,6 @@ import ReverseProxiesProvider, {
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
@@ -431,7 +430,6 @@ const PeerOverviewTabContent = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings();
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
return (
<div className={"px-8"}>
@@ -460,14 +458,11 @@ const PeerOverviewTabContent = () => {
</div>
)}
{isSSHSupported && <PeerSSHToggle />}
<PeerSSHToggle />
<div>
<Label>Remote Access</Label>
<HelpText>
Connect directly to this peer via{" "}
{isSSHSupported ? "SSH or RDP" : "RDP"}.
</HelpText>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
+3 -3
View File
@@ -13,7 +13,7 @@ import {
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import {
usesStandardSSHPort,
isNativeSSHSupported,
isNetbirdSSHProtocolSupported,
} from "@utils/version";
@@ -95,7 +95,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
@@ -122,7 +122,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
connected.current = true;
try {
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

+5
View File
@@ -11,6 +11,7 @@ export interface PostureCheck {
geo_location_check?: GeoLocationCheck;
peer_network_range_check?: PeerNetworkRangeCheck;
process_check?: ProcessCheck;
certificate_check?: CertificateCheck;
};
policies?: Policy[];
active?: boolean;
@@ -65,6 +66,10 @@ export interface Process {
windows_path?: string;
}
export interface CertificateCheck {
ca_certificates: string[];
}
export const windowsKernelVersions: SelectOption[] = [
{ value: "10.0", label: "Windows 10" },
{ value: "10.0.2", label: "Windows 11" },
+36 -55
View File
@@ -11,19 +11,19 @@ import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator";
import Steps from "@components/Steps";
import { Lightbox } from "@components/ui/Lightbox";
import { Mark } from "@components/ui/Mark";
import { cn } from "@utils/helpers";
import { ExternalLinkIcon, PlusCircle, TerminalSquare } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import sshImage from "@/assets/ssh/ssh-client.png";
import { SegmentedTabs } from "@components/SegmentedTabs";
import AndroidIcon from "@/assets/icons/AndroidIcon";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { isNetbirdSSHProtocolSupported } from "@utils/version";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
type Props = {
open?: boolean;
@@ -39,11 +39,6 @@ export const PeerSSHInstructions = ({
peer,
}: Props) => {
const [client, setClient] = useState("cli");
// Peers already on v0.61.0+ don't need to be told which release started
// requiring a policy.
const peerRequiresSSHPolicy = isNetbirdSSHProtocolSupported(
peer?.version ?? "",
);
const [policyModal, setPolicyModal] = useState(false);
// Enabling the SSH server and root login require root, or an administrator on
@@ -80,60 +75,46 @@ export const PeerSSHInstructions = ({
<NetBirdIcon size={16} />
Desktop Client
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"android"}>
<AndroidIcon size={16} className={"fill-nb-gray-400"} />
Android
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
<Steps>
<Steps.Step step={1}>
{client === "cli" && (
<>
<p className={"font-normal"}>
If you are using NetBird via CLI, you can enable SSH by
running{" "}
{isWindows
? "these commands in an elevated prompt"
: "these commands as root"}
. Run the first one only if NetBird is already running. On a
machine where you do not have those rights, an administrator
has to run them.
</p>
<Code codeToCopy={`${prefix}netbird down`}>
<Code.Line>{`${prefix}netbird down`}</Code.Line>
</Code>
<Code>
<Code.Line>{`${prefix}netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line>
</Code>
</>
)}
{client === "gui" && (
{client === "cli" ? (
<Steps.Step step={1}>
<p className={"font-normal"}>
If you are using NetBird via CLI, you can enable SSH by
running{" "}
{isWindows
? "these commands in an elevated prompt"
: "these commands as root"}
. Run the first one only if NetBird is already running. On a
machine where you do not have those rights, an administrator
has to run them.
</p>
<Code codeToCopy={`${prefix}netbird down`}>
<Code.Line>{`${prefix}netbird down`}</Code.Line>
</Code>
<Code>
<Code.Line>{`${prefix}netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line>
</Code>
</Steps.Step>
) : (
<Steps.Step step={1}>
<p className={"font-normal"}>
If you are using NetBird via the Desktop Client, click on the
NetBird tray icon, open <Mark>Settings</Mark> and turn on{" "}
<Mark>Enable SSH Server</Mark> on the <Mark>SSH</Mark> tab. To
log in as {isWindows ? "an administrator" : "root"}, enable{" "}
<Mark>Allow Root Login</Mark> on the same tab.
NetBird tray icon, go to <Mark>Settings</Mark> and click{" "}
<Mark>Allow SSH</Mark>. If you want to enable Root Login go to{" "}
<Mark>Settings &gt; Advanced Settings</Mark> and enable SSH
Root Login under the SSH tab.
</p>
)}
{client === "android" && (
<p className={"font-normal"}>
If you are using NetBird on Android, open the app, tap{" "}
<Mark>Settings</Mark>, then <Mark>Advanced</Mark> and turn on{" "}
<Mark>Enable SSH Server</Mark>.
</p>
)}
</Steps.Step>
<Lightbox image={sshImage} />
</Steps.Step>
)}
<Steps.Step step={2}>
<p className={"font-normal"}>
{peerRequiresSSHPolicy
? "SSH requires an explicit access control policy to allow SSH connections to this machine."
: "Starting from NetBird v0.61.0, SSH requires an explicit access control policy to allow SSH connections to this machine."}
Starting from NetBird v0.61.0, SSH requires an explicit access
control policy to allow SSH connections to this machine.
</p>
<div className={"mt-2"}>
<Button
@@ -147,8 +128,8 @@ export const PeerSSHInstructions = ({
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Once the NetBird SSH server is enabled on the client, <br />
click <Mark>Finish Setup</Mark> below to complete the setup.
Once the NetBird SSH server is allowed on the client, <br />
click <Mark>Confirm & Enable</Mark> below to finish the setup.
</p>
</Steps.Step>
</Steps>
+2 -2
View File
@@ -1,7 +1,7 @@
import useFetchApi from "@utils/api";
import { Peer } from "@/interfaces/Peer";
import { Policy } from "@/interfaces/Policy";
import { usesStandardSSHPort } from "@utils/version";
import { isNativeSSHSupported } from "@utils/version";
export const usePeerSSHPolicyCheck = (peer?: Peer) => {
const { data: policies, isLoading } = useFetchApi<Policy[]>(
@@ -66,7 +66,7 @@ export const usePeerSSHPolicyCheck = (peer?: Peer) => {
!hasSSHPolicy &&
!isLoading &&
!!peer?.ssh_enabled &&
usesStandardSSHPort(peer.version);
isNativeSSHSupported(peer.version);
return {
peerPolicies,
+13 -6
View File
@@ -8,6 +8,7 @@ import {
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconInfoCircle } from "@tabler/icons-react";
import {
CheckCircle2,
@@ -26,9 +27,9 @@ import { useSWRConfig } from "swr";
import { useBypass, useBypassedPeers } from "@/cloud/edr/useBypass";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
import { useIntegrations } from "@/modules/integrations/edr/useIntegrations";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import InlineLink from "@components/InlineLink";
@@ -125,15 +126,21 @@ export default function PeerActionCell() {
const showRevokeBypass = isBypassed && canBypass;
const showApprovalGroup = showApprove || showBypass || showRevokeBypass;
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
const showSSHButton = useMemo(() => {
if (!isSSHSupported) return false;
const isClientSSHEnabled = peer?.local_flags?.server_ssh_allowed;
const isDashboardSSHEnabled = peer?.ssh_enabled;
if (isDashboardSSHEnabled) return true;
return !isClientSSHEnabled;
}, [peer, isSSHSupported]);
}, [peer]);
// The Connect column previously hosted SSH / RDP entry points. We
// fold those into the action menu — gated on a non-mobile, online
// peer — so the table loses a column and the connect affordance is
// one click away inside the three-dot menu.
const peerOs = getOperatingSystem(peer?.os);
const isMobile =
peerOs === OperatingSystem.ANDROID || peerOs === OperatingSystem.IOS;
const showRemoteAccessItems = !isMobile && !!peer.connected;
const toggleLoginExpiration = async () => {
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
@@ -248,7 +255,7 @@ export default function PeerActionCell() {
</>
)}
{peer.connected && (
{showRemoteAccessItems && (
<>
<DropdownMenuSeparator />
<SSHButton peer={peer} isDropdown={true} />
+6
View File
@@ -4,9 +4,11 @@ import {
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconChevronDown } from "@tabler/icons-react";
import * as React from "react";
import { usePeer } from "@/contexts/PeerProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { cn } from "@utils/helpers";
@@ -14,6 +16,10 @@ import { cn } from "@utils/helpers";
export const PeerConnectButton = () => {
const { peer } = usePeer();
const isConnected = peer.connected;
const os = getOperatingSystem(peer?.os);
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
if (isMobile) return;
return isConnected ? (
<>
@@ -0,0 +1,212 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import { ModalClose, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import { Textarea } from "@components/Textarea";
import { uniqueId } from "lodash";
import {
ExternalLinkIcon,
FileKey2Icon,
MinusCircleIcon,
PlusCircle,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { CertificateCheck } from "@/interfaces/PostureCheck";
import {
isValidPEMCertificate,
useCertificateFingerprints,
} from "@/modules/posture-checks/helper/CertificateHelper";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: CertificateCheck;
onChange: (value: CertificateCheck | undefined) => void;
disabled?: boolean;
};
export const PostureCheckCertificate = ({
value,
onChange,
disabled,
}: Props) => {
const [open, setOpen] = useState(false);
return (
<PostureCheckCard
open={open}
setOpen={setOpen}
key={open ? 1 : 0}
active={value?.ca_certificates && value.ca_certificates.length > 0}
title={"Certificate"}
description={
"Restrict access to peers holding a certificate issued by your own certificate authority."
}
icon={<FileKey2Icon size={18} />}
iconClass={"bg-gradient-to-tr from-purple-500 to-purple-400"}
modalWidthClass={"max-w-2xl"}
onReset={() => onChange(undefined)}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
disabled={disabled}
/>
</PostureCheckCard>
);
};
type CACertificate = {
id: string;
pem: string;
};
const newCertificate = (pem = ""): CACertificate => ({
id: uniqueId("ca-certificate"),
pem,
});
const rowsFor = (pem: string) =>
Math.min(Math.max(pem.split("\n").length + 1, 6), 32);
const CheckContent = ({ value, onChange, disabled }: Props) => {
const [certificates, setCertificates] = useState<CACertificate[]>(
value?.ca_certificates?.length
? value.ca_certificates.map((pem) => newCertificate(pem))
: [newCertificate()],
);
const pems = useMemo(() => certificates.map((c) => c.pem), [certificates]);
const fingerprints = useCertificateFingerprints(pems);
const errors = useMemo(
() =>
certificates.map((c) =>
c.pem.trim() !== "" && !isValidPEMCertificate(c.pem)
? "Please paste a valid PEM encoded certificate"
: "",
),
[certificates],
);
const hasErrorsOrIsEmpty =
certificates.length === 0 ||
certificates.some((c) => c.pem.trim() === "") ||
errors.some((e) => e !== "");
const updateCertificate = (id: string, pem: string) => {
setCertificates(certificates.map((c) => (c.id === id ? { ...c, pem } : c)));
};
const removeCertificate = (id: string) => {
setCertificates(certificates.filter((c) => c.id !== id));
};
return (
<>
<div className={"flex flex-col px-8 gap-2 pb-6"}>
<div className={"flex justify-between items-start gap-10 mt-2"}>
<div>
<Label>CA Certificates</Label>
<HelpText className={""}>
Paste the PEM encoded public certificate of the root or
intermediate CA that issues your device certificates. Peers will
only be allowed to connect if they hold a certificate issued by
one of these CAs and prove possession of its private key.
</HelpText>
</div>
</div>
{certificates.length > 0 && (
<div className={"mb-2 flex flex-col gap-4 w-full"}>
{certificates.map((c, index) => {
return (
<div key={c.id} className={"flex gap-2 items-start min-w-0"}>
<div className={"w-full flex flex-col gap-1.5 min-w-0"}>
<Textarea
value={c.pem}
rows={rowsFor(c.pem)}
placeholder={
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
}
error={errors[index]}
className={
"w-full font-mono text-xs leading-5 overflow-auto"
}
onChange={(e) => updateCertificate(c.id, e.target.value)}
disabled={disabled}
/>
{fingerprints[index] && (
<span
className={
"text-xs text-nb-gray-400 font-mono break-all"
}
>
SHA-256 {fingerprints[index]}
</span>
)}
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeCertificate(c.id)}
disabled={disabled}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
)}
<Button
variant={"dotted"}
size={"sm"}
onClick={() => setCertificates([...certificates, newCertificate()])}
className={"mt-1"}
disabled={disabled}
>
<PlusCircle size={16} />
Add CA Certificate
</Button>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-posture-checks#certificate-check"
}
target={"_blank"}
>
Certificate Check
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={hasErrorsOrIsEmpty || disabled}
onClick={() =>
onChange({
ca_certificates: certificates.map((c) => c.pem.trim()),
})
}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};
@@ -0,0 +1,73 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { ScrollArea } from "@components/ScrollArea";
import { FileKey2Icon } from "lucide-react";
import * as React from "react";
import { CertificateCheck } from "@/interfaces/PostureCheck";
import { useCertificateFingerprints } from "@/modules/posture-checks/helper/CertificateHelper";
type Props = {
check?: CertificateCheck;
children?: React.ReactNode;
};
export const CertificateTooltip = ({ check, children }: Props) => {
const fingerprints = useCertificateFingerprints(check?.ca_certificates ?? []);
return check ? (
<FullTooltip
className={"w-full min-w-0"}
interactive={true}
contentClassName={"p-0"}
content={
<div
className={
"text-neutral-300 text-sm max-w-xs flex flex-col gap-1 min-w-0"
}
>
<div className={"px-4 pt-3"}>
<span>
<span className={"text-green-500 font-semibold"}>Allow only</span>{" "}
peers holding a certificate issued by one of the following CAs
</span>
</div>
<ScrollArea
className={
"max-h-[275px] overflow-y-auto flex flex-col px-4 min-w-0"
}
>
<div className={"flex flex-col gap-2 mt-1 text-xs mb-3.5 min-w-0"}>
{check.ca_certificates.map((_, index) => {
const fingerprint = fingerprints[index];
return (
<Badge
key={index}
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<FileKey2Icon size={12} />
</span>
<span
className={"truncate inline-block font-mono"}
title={fingerprint}
>
{fingerprint
? `SHA-256 ${fingerprint}`
: `CA certificate ${index + 1}`}
</span>
</Badge>
);
})}
</div>
</ScrollArea>
</div>
}
>
{children}
</FullTooltip>
) : (
children
);
};
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
const pemCertificateBlock =
/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/g;
const base64 = /^[A-Za-z0-9+/]+={0,2}$/;
// Returns the base64 bodies of all CERTIFICATE blocks, or undefined when the input is
// not exclusively made of well-formed blocks.
export const pemCertificateBodies = (pem: string): string[] | undefined => {
const bodies: string[] = [];
const rest = pem.replace(pemCertificateBlock, (_, body: string) => {
bodies.push(body.replace(/\s+/g, ""));
return "";
});
if (bodies.length === 0 || rest.trim() !== "") return undefined;
const wellFormed = bodies.every(
(b) => b.length > 0 && b.length % 4 === 0 && base64.test(b),
);
return wellFormed ? bodies : undefined;
};
export const isValidPEMCertificate = (pem: string) =>
pemCertificateBodies(pem) !== undefined;
// SHA-256 fingerprint of the first certificate in the PEM, as colon separated hex.
export const certificateFingerprint = async (
pem: string,
): Promise<string | undefined> => {
const bodies = pemCertificateBodies(pem);
if (!bodies || typeof crypto === "undefined" || !crypto.subtle) return;
const der = Uint8Array.from(atob(bodies[0]), (c) => c.charCodeAt(0));
const digest = await crypto.subtle.digest("SHA-256", der);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join(":");
};
export const useCertificateFingerprints = (pems: string[]) => {
const [fingerprints, setFingerprints] = useState<(string | undefined)[]>([]);
const key = JSON.stringify(pems);
useEffect(() => {
let cancelled = false;
const list: string[] = JSON.parse(key);
Promise.all(list.map(certificateFingerprint)).then((result) => {
if (!cancelled) setFingerprints(result);
});
return () => {
cancelled = true;
};
}, [key]);
return fingerprints;
};
@@ -14,6 +14,7 @@ import { ExternalLinkIcon, LayoutList, ShieldCheck, Text } from "lucide-react";
import React, { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCertificate } from "@/modules/posture-checks/checks/PostureCheckCertificate";
import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/PostureCheckGeoLocation";
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
@@ -56,7 +57,8 @@ export default function PostureCheckModal({
!!check?.checks?.geo_location_check ||
!!check?.checks?.os_version_check ||
!!check?.checks?.peer_network_range_check ||
!!check?.checks.process_check;
!!check?.checks.process_check ||
!!check?.checks?.certificate_check;
const canCreate =
!isEmpty(check?.name) &&
isAtLeastOneCheckEnabled &&
@@ -165,6 +167,18 @@ export default function PostureCheckModal({
!permission.policies.create || !permission.policies.update
}
/>
<PostureCheckCertificate
value={check?.checks?.certificate_check}
onChange={(v) =>
setCheck({
type: "certificate_check",
payload: v,
})
}
disabled={
!permission.policies.create || !permission.policies.update
}
/>
</>
</TabsContent>
<TabsContent value={"general"} className={"pb-8 px-8"}>
@@ -1,8 +1,15 @@
import { cn } from "@utils/helpers";
import { Disc3Icon, FlagIcon, NetworkIcon, ServerCogIcon } from "lucide-react";
import {
Disc3Icon,
FileKey2Icon,
FlagIcon,
NetworkIcon,
ServerCogIcon,
} from "lucide-react";
import * as React from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { CertificateTooltip } from "@/modules/posture-checks/checks/tooltips/CertificateTooltip";
import { GeoLocationTooltip } from "@/modules/posture-checks/checks/tooltips/GeoLocationTooltip";
import { NetBirdVersionTooltip } from "@/modules/posture-checks/checks/tooltips/NetBirdVersionTooltip";
import { OperatingSystemTooltip } from "@/modules/posture-checks/checks/tooltips/OperatingSystemTooltip";
@@ -96,6 +103,18 @@ export const PostureCheckChecksCell = ({
</div>
</ProcessTooltip>
)}
{check.checks.certificate_check && (
<CertificateTooltip check={check.checks.certificate_check}>
<div
className={cn(
"bg-gradient-to-tr from-purple-500 to-purple-400 h-8 w-8 rounded-full flex items-center justify-center relative z-[8] hover:scale-[1.1] transition-all",
)}
>
<FileKey2Icon size={14} />
</div>
</CertificateTooltip>
)}
</div>
{children}
</div>
+11 -1
View File
@@ -22,7 +22,8 @@ interface PostureCheckAction {
| "location"
| "os"
| "network_range"
| "process_check";
| "process_check"
| "certificate_check";
payload: any;
}
@@ -79,6 +80,15 @@ const postureCheckReducer = (
},
};
case "certificate_check":
return {
...state,
checks: {
...state.checks,
certificate_check: action.payload,
},
};
default:
return state;
}
-12
View File
@@ -1,12 +0,0 @@
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
/**
* Check if the NetBird SSH server can run on the peer's operating system.
* The client ships it everywhere except iOS.
*
* There is no equivalent for RDP: the server is not ours, so any operating
* system may be running one.
*/
export const isSSHSupportedOnOS = (os?: string) =>
getOperatingSystem(os ?? "") !== OperatingSystem.IOS;
@@ -1,8 +1,10 @@
import Button from "@components/Button";
import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, MonitorIcon } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
@@ -11,9 +13,18 @@ type Props = {
isDropdown?: boolean;
};
const RDP_SUPPORTED_OS = new Set([
OperatingSystem.WINDOWS,
OperatingSystem.LINUX,
OperatingSystem.FREEBSD,
]);
export const RDPButton = ({ peer, isDropdown = false }: Props) => {
const { permission } = usePermissions();
const os = getOperatingSystem(peer?.os);
if (!RDP_SUPPORTED_OS.has(os)) return null;
const disabled = !peer.connected || !permission.peers.update;
const hasPermission = permission.peers.update;
+46 -38
View File
@@ -7,7 +7,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Peer } from "@/interfaces/Peer";
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
type Props = {
peer: Peer;
@@ -24,45 +25,52 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
const hasPermission = permission.peers.update;
if (!isSSHSupportedOnOS(peer?.os)) return null;
const os = getOperatingSystem(peer?.os);
const isSSHSupported = os !== OperatingSystem.IOS;
return (
<>
{modal && (
<SSHCredentialsModal open={modal} onOpenChange={setModal} peer={peer} />
)}
<div>
<SSHTooltip
isOnline={peer.connected}
isSSHEnabled={isSSHEnabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>
{isDropdown ? (
<DropdownMenuItem
onClick={() => setModal(true)}
disabled={disabled}
className={"w-full"}
>
<div className={"flex gap-3 items-center w-full"}>
<TerminalIcon size={14} className={"shrink-0"} />
isSSHSupported && (
<>
{modal && (
<SSHCredentialsModal
open={modal}
onOpenChange={setModal}
peer={peer}
/>
)}
<div>
<SSHTooltip
isOnline={peer.connected}
isSSHEnabled={isSSHEnabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>
{isDropdown ? (
<DropdownMenuItem
onClick={() => setModal(true)}
disabled={disabled}
className={"w-full"}
>
<div className={"flex gap-3 items-center w-full"}>
<TerminalIcon size={14} className={"shrink-0"} />
SSH
</div>
</DropdownMenuItem>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => setModal(true)}
disabled={disabled}
>
<TerminalIcon size={16} />
SSH
</div>
</DropdownMenuItem>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => setModal(true)}
disabled={disabled}
>
<TerminalIcon size={16} />
SSH
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</SSHTooltip>
</div>
</>
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</SSHTooltip>
</div>
</>
)
);
};
@@ -13,7 +13,7 @@ import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { usesStandardSSHPort } from "@utils/version";
import { isNativeSSHSupported } from "@utils/version";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
@@ -39,7 +39,7 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
: "root",
);
const initialPort = usesStandardSSHPort(peer.version) ? "22" : "44338";
const initialPort = isNativeSSHSupported(peer.version) ? "22" : "44338";
const [port, setPort] = useState(initialPort);
const userNameError = useMemo(() => {
+1
View File
@@ -51,6 +51,7 @@ export const testEditionOverride = (): Edition | undefined => {
// cloud infrastructure (billing, MSP, trial, hosted integrations).
// The hostname fallback keeps deployments without NETBIRD_CLOUD working.
export const isNetBirdCloud = () => {
return false
const override = testEditionOverride();
if (override) return override === "cloud";
if (process.env.APP_ENV === "test") return true;
+3 -15
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { compareVersions, isNewerVersion, usesStandardSSHPort } from "./version";
import { compareVersions, isNativeSSHSupported, isNewerVersion } from "./version";
describe("usesStandardSSHPort", () => {
describe("isNativeSSHSupported", () => {
it.each([
{ version: "v0.59.9", shouldSupport: false, desc: "below minimum" },
{ version: "v0.59.10", shouldSupport: false, desc: "below minimum" },
@@ -36,14 +36,8 @@ describe("usesStandardSSHPort", () => {
shouldSupport: true,
desc: "CI build suffix",
},
{
version: "dev-16f7e1e14",
shouldSupport: true,
desc: "commit-stamped mobile build",
},
{ version: "", shouldSupport: true, desc: "unreported version" },
])("$version → $shouldSupport ($desc)", ({ version, shouldSupport }) => {
expect(usesStandardSSHPort(version)).toBe(shouldSupport);
expect(isNativeSSHSupported(version)).toBe(shouldSupport);
});
});
@@ -114,12 +108,6 @@ describe("isNewerVersion (update available)", () => {
expected: false,
desc: "development build",
},
{
current: "dev-16f7e1e14",
latest: "0.77.0",
expected: false,
desc: "commit-stamped mobile build",
},
{ current: "0.77.0", latest: "", expected: false, desc: "unknown latest" },
])("$current → $latest → $expected ($desc)", ({ current, latest, expected }) => {
expect(isNewerVersion(current, latest)).toBe(expected);
Loaded 20 of 21 files, more files were not shown because too many files have changed in this diff. Show more