Add ssh policy info for peers (#511)

This commit is contained in:
Eduard Gert
2025-11-20 14:29:14 +01:00
committed by GitHub
parent d81b75a946
commit 936de0f4f3
9 changed files with 261 additions and 29 deletions

View File

@@ -4,7 +4,18 @@ import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
const initialAnnouncements: Announcement[] = []; const initialAnnouncements: Announcement[] = [
{
tag: "New",
text: "NetBird v0.60.0 - Identity-aware, private SSH over your NetBird network.",
link: "https://docs.netbird.io/how-to/ssh",
linkText: "Documentation",
variant: "default", // "default" or "important"
isExternal: true,
closeable: true,
isCloudOnly: false,
},
];
export interface Announcement extends AnnouncementVariant { export interface Announcement extends AnnouncementVariant {
tag: string; tag: string;

View File

@@ -138,6 +138,7 @@ export default function PeerProvider({
<PeerSSHInstructions <PeerSSHInstructions
open={sshInstructionsModal} open={sshInstructionsModal}
onOpenChange={setSSHInstructionsModal} onOpenChange={setSSHInstructionsModal}
peer={peer}
onSuccess={() => toggleSSH(true)} onSuccess={() => toggleSSH(true)}
/> />
)} )}

View File

@@ -45,7 +45,7 @@ import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group"; import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy"; import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck"; import { PostureCheck } from "@/interfaces/PostureCheck";
import { useAccessControl } from "@/modules/access-control/useAccessControl"; import { useAccessControl } from "@/modules/access-control/useAccessControl";
import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab"; import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab";
@@ -116,6 +116,9 @@ type ModalProps = {
postureCheckTemplates?: PostureCheck[]; postureCheckTemplates?: PostureCheck[];
useSave?: boolean; useSave?: boolean;
allowEditPeers?: boolean; allowEditPeers?: boolean;
initialProtocol?: Protocol;
initialPorts?: number[];
initialDestinationResource?: PolicyRuleResource;
}; };
export function AccessControlModalContent({ export function AccessControlModalContent({
@@ -128,6 +131,9 @@ export function AccessControlModalContent({
initialDestinationGroups, initialDestinationGroups,
initialName, initialName,
initialDescription, initialDescription,
initialProtocol,
initialPorts,
initialDestinationResource,
}: Readonly<ModalProps>) { }: Readonly<ModalProps>) {
const { permission } = usePermissions(); const { permission } = usePermissions();
@@ -170,6 +176,9 @@ export function AccessControlModalContent({
initialDestinationGroups, initialDestinationGroups,
initialName, initialName,
initialDescription, initialDescription,
initialPorts,
initialProtocol,
initialDestinationResource,
}); });
const [tab, setTab] = useState(() => { const [tab, setTab] = useState(() => {

View File

@@ -6,7 +6,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { usePolicies } from "@/contexts/PoliciesProvider"; import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group"; import { Group } from "@/interfaces/Group";
import { Policy, PortRange, Protocol } from "@/interfaces/Policy"; import {
Policy,
PolicyRuleResource,
PortRange,
Protocol,
} from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck"; import { PostureCheck } from "@/interfaces/PostureCheck";
import useGroupHelper from "@/modules/groups/useGroupHelper"; import useGroupHelper from "@/modules/groups/useGroupHelper";
import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck"; import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck";
@@ -18,6 +23,9 @@ type Props = {
initialDestinationGroups?: Group[] | string[]; initialDestinationGroups?: Group[] | string[];
initialName?: string; initialName?: string;
initialDescription?: string; initialDescription?: string;
initialProtocol?: Protocol;
initialPorts?: number[];
initialDestinationResource?: PolicyRuleResource;
}; };
// TODO add reducer // TODO add reducer
@@ -29,6 +37,9 @@ export const useAccessControl = ({
initialName, initialName,
initialDescription, initialDescription,
onSuccess, onSuccess,
initialProtocol,
initialPorts,
initialDestinationResource,
}: Props = {}) => { }: Props = {}) => {
const { data: allPostureChecks, isLoading: isPostureChecksLoading } = const { data: allPostureChecks, isLoading: isPostureChecksLoading } =
useFetchApi<PostureCheck[]>("/posture-checks"); useFetchApi<PostureCheck[]>("/posture-checks");
@@ -75,6 +86,7 @@ export const useAccessControl = ({
const [enabled, setEnabled] = useState<boolean>(policy?.enabled ?? true); const [enabled, setEnabled] = useState<boolean>(policy?.enabled ?? true);
const [ports, setPorts] = useState<number[]>(() => { const [ports, setPorts] = useState<number[]>(() => {
if (initialPorts) return initialPorts;
if (!firstRule) return []; if (!firstRule) return [];
if (firstRule.ports == undefined) return []; if (firstRule.ports == undefined) return [];
if (firstRule.ports.length > 0) { if (firstRule.ports.length > 0) {
@@ -93,7 +105,7 @@ export const useAccessControl = ({
}); });
const [protocol, setProtocol] = useState<Protocol>( const [protocol, setProtocol] = useState<Protocol>(
firstRule ? firstRule.protocol : "all", firstRule ? firstRule.protocol : initialProtocol ?? "all",
); );
const [direction, setDirection] = useState<Direction>(() => { const [direction, setDirection] = useState<Direction>(() => {
if (!firstRule) return "bi"; if (!firstRule) return "bi";
@@ -131,7 +143,7 @@ export const useAccessControl = ({
); );
const [destinationResource, setDestinationResource] = useState( const [destinationResource, setDestinationResource] = useState(
firstRule?.destinationResource, firstRule?.destinationResource ?? initialDestinationResource,
); );
const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({}); const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({});

View File

@@ -9,26 +9,37 @@ import {
} from "@components/modal/Modal"; } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader"; import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph"; import Paragraph from "@components/Paragraph";
import { SegmentedTabs } from "@components/SegmentedTabs";
import Separator from "@components/Separator"; import Separator from "@components/Separator";
import Steps from "@components/Steps"; import Steps from "@components/Steps";
import { Lightbox } from "@components/ui/Lightbox"; import { Lightbox } from "@components/ui/Lightbox";
import { Mark } from "@components/ui/Mark"; import { Mark } from "@components/ui/Mark";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { ExternalLinkIcon, TerminalSquare } from "lucide-react"; import { ExternalLinkIcon, PlusCircle, TerminalSquare } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import sshImage from "@/assets/ssh/ssh-client.png"; import sshImage from "@/assets/ssh/ssh-client.png";
import { Peer } from "@/interfaces/Peer";
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
import { Terminal } from "@/modules/remote-access/ssh/Terminal";
type Props = { type Props = {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
onSuccess?: () => void; onSuccess?: () => void;
peer?: Peer;
}; };
export const PeerSSHInstructions = ({ export const PeerSSHInstructions = ({
open, open,
onOpenChange, onOpenChange,
onSuccess, onSuccess,
peer,
}: Props) => { }: Props) => {
const [client, setClient] = useState("cli");
const [policyModal, setPolicyModal] = useState(false);
return ( return (
<Modal open={open} onOpenChange={onOpenChange}> <Modal open={open} onOpenChange={onOpenChange}>
<ModalContent <ModalContent
@@ -39,36 +50,70 @@ export const PeerSSHInstructions = ({
icon={<TerminalSquare size={16} className={"text-netbird"} />} icon={<TerminalSquare size={16} className={"text-netbird"} />}
title={"Enable SSH Access"} title={"Enable SSH Access"}
description={ description={
"Allow remote SSH access to this machine from other connected network participants." "Allow remote SSH access from other connected network participants."
} }
color={"netbird"} color={"netbird"}
/> />
<Separator /> <Separator />
<div className={"px-8 py-3 flex flex-col gap-0 z-0"}> <div className={"px-8 py-3 flex flex-col gap-0 z-0 mt-1"}>
<SegmentedTabs value={client} onChange={setClient}>
<SegmentedTabs.List className={"rounded-lg border"}>
<SegmentedTabs.Trigger value={"cli"}>
<TerminalSquare size={16} />
CLI
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"gui"}>
<NetBirdIcon size={16} />
Desktop Client
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
<Steps> <Steps>
<Steps.Step step={1}> {client === "cli" ? (
<p className={"font-normal"}> <Steps.Step step={1}>
If you are using NetBird via CLI, you can enable SSH by running <p className={"font-normal"}>
</p> If you are using NetBird via CLI, you can enable SSH by
<Code codeToCopy={"netbird down"}> running
<Code.Line>{`netbird down # if NetBird is already running`}</Code.Line> </p>
</Code> <Code codeToCopy={"netbird down"}>
<Code> <Code.Line>{`netbird down # if NetBird is already running`}</Code.Line>
<Code.Line>{`netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line> </Code>
</Code> <Code>
</Steps.Step> <Code.Line>{`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, 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>
<Lightbox image={sshImage} />
</Steps.Step>
)}
<Steps.Step step={2}> <Steps.Step step={2}>
<p className={"font-normal"}> <p className={"font-normal"}>
If you are using NetBird via the Desktop Client, click on the Starting from NetBird v0.60.0, SSH requires an explicit access
NetBird tray icon, go to <Mark>Settings</Mark> and click{" "} control policy that allows <Mark>TCP</Mark> traffic on port{" "}
<Mark>Allow SSH</Mark> <br /> <Mark>22</Mark>
</p> </p>
<Lightbox image={sshImage} /> <div className={"mt-2"}>
<Button
variant={"secondary"}
onClick={() => setPolicyModal(true)}
>
<PlusCircle size={16} />
Create SSH Policy
</Button>
</div>
</Steps.Step> </Steps.Step>
<Steps.Step step={3} line={false}> <Steps.Step step={3} line={false}>
<p className={"font-normal"}> <p className={"font-normal"}>
Once the NetBird SSH server is allowed on the client, <br /> Once the NetBird SSH server is allowed on the client, <br />
@@ -96,15 +141,17 @@ export const PeerSSHInstructions = ({
<Button variant={"secondary"}>Cancel</Button> <Button variant={"secondary"}>Cancel</Button>
</ModalClose> </ModalClose>
<Button <Button variant={"primary"} onClick={onSuccess}>
variant={"primary"}
onClick={onSuccess}
data-cy={"create-setup-key"}
>
Confirm & Enable Confirm & Enable
</Button> </Button>
</div> </div>
</ModalFooter> </ModalFooter>
<PeerSSHPolicyModal
open={policyModal}
onOpenChange={setPolicyModal}
peer={peer}
/>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );

View File

@@ -0,0 +1,38 @@
import { Callout } from "@components/Callout";
import { InlineButtonLink } from "@components/InlineLink";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useState } from "react";
import { Peer } from "@/interfaces/Peer";
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
import { usePeerSSHPolicyCheck } from "@/modules/peer/usePeerSSHPolicyCheck";
type Props = {
peer?: Peer;
className?: string;
};
export const PeerSSHPolicyInfo = ({ peer, className }: Props) => {
const { showSSHPolicyInfo } = usePeerSSHPolicyCheck(peer);
const [policyModal, setPolicyModal] = useState(false);
return (
showSSHPolicyInfo && (
<>
<Callout className={cn("max-w-xl", className)} variant={"warning"}>
<span>
Starting from NetBird v0.60.0, SSH requires an explicit access
control policy that allows TCP traffic on port 22.{" "}
<InlineButtonLink onClick={() => setPolicyModal(true)}>
Create SSH Policy
</InlineButtonLink>
</span>
</Callout>
<PeerSSHPolicyModal
open={policyModal}
onOpenChange={setPolicyModal}
peer={peer}
/>
</>
)
);
};

View File

@@ -0,0 +1,35 @@
import { Modal } from "@components/modal/Modal";
import * as React from "react";
import { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
peer?: Peer;
};
export const PeerSSHPolicyModal = ({ open, onOpenChange, peer }: Props) => {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<AccessControlModalContent
key={open ? "1" : "0"}
initialPorts={[22]}
initialProtocol={"tcp"}
initialName={"SSH Access"}
initialDestinationResource={
peer
? ({
id: peer.id,
type: "peer",
} as PolicyRuleResource)
: undefined
}
onSuccess={async (p) => {
onOpenChange(false);
}}
/>
</Modal>
);
};

View File

@@ -4,6 +4,7 @@ import { LockIcon, TerminalSquare } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { usePeer } from "@/contexts/PeerProvider"; import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import { PeerSSHPolicyInfo } from "@/modules/peer/PeerSSHPolicyInfo";
export const PeerSSHToggle = () => { export const PeerSSHToggle = () => {
const { permission } = usePermissions(); const { permission } = usePermissions();
@@ -42,6 +43,7 @@ export const PeerSSHToggle = () => {
} }
/> />
</FullTooltip> </FullTooltip>
<PeerSSHPolicyInfo peer={peer} />
</> </>
); );
}; };

View File

@@ -0,0 +1,77 @@
import useFetchApi from "@utils/api";
import { Peer } from "@/interfaces/Peer";
import { Policy } from "@/interfaces/Policy";
import { isNativeSSHSupported } from "@utils/version";
export const usePeerSSHPolicyCheck = (peer?: Peer) => {
const { data: policies, isLoading } = useFetchApi<Policy[]>(
"/policies",
true,
false,
);
const peerGroupIds = peer?.groups?.map((p) => p.id);
const peerPolicies = policies?.filter((policy) => {
// Skip disabled policies
if (!policy?.enabled) return false;
const rule = policy?.rules?.[0];
if (!rule) return false;
// Skip icmp and udp
if (rule.protocol === "icmp" || rule.protocol === "udp") return false;
// Check resource and groups
const isPeerInDestinationResource =
rule.destinationResource?.id === peer?.id;
const isPeerInDestinationGroup =
rule.destinations?.some((group) => {
const groupId = typeof group === "string" ? group : group?.id;
return peerGroupIds?.includes(groupId);
}) ?? false;
const isPeerInDestination =
isPeerInDestinationResource || isPeerInDestinationGroup;
// If bidirectional, also check if peer is in source
let isPeerInSource = false;
if (rule.bidirectional) {
const isPeerInSourceResource = rule.sourceResource?.id === peer?.id;
const isPeerInSourceGroup =
rule.sources?.some((group) => {
const groupId = typeof group === "string" ? group : group?.id;
return peerGroupIds?.includes(groupId);
}) ?? false;
isPeerInSource = isPeerInSourceResource || isPeerInSourceGroup;
}
const isInSourceOrDestination = isPeerInDestination || isPeerInSource;
if (!isInSourceOrDestination) return false;
if (rule.protocol === "all") return true;
// Check ports
const hasNoPortRestrictions = rule.ports === undefined;
const hasExplicitPort22 = rule.ports?.includes("22");
const hasPort22InRange = rule.port_ranges?.some(
(range) => 22 >= range.start && 22 <= range.end,
);
return hasNoPortRestrictions || hasExplicitPort22 || hasPort22InRange;
});
const hasSSHPolicy = (peerPolicies?.length ?? 0) > 0;
const showSSHPolicyInfo =
!hasSSHPolicy &&
!isLoading &&
!!peer?.ssh_enabled &&
isNativeSSHSupported(peer.version);
return {
peerPolicies,
isCheckLoading: isLoading,
hasSSHPolicy,
showSSHPolicyInfo,
};
};