diff --git a/src/contexts/AnnouncementProvider.tsx b/src/contexts/AnnouncementProvider.tsx index d50b1cfc..2a3e2e2c 100644 --- a/src/contexts/AnnouncementProvider.tsx +++ b/src/contexts/AnnouncementProvider.tsx @@ -4,7 +4,18 @@ import md5 from "crypto-js/md5"; import React, { useEffect, useState } from "react"; 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 { tag: string; diff --git a/src/contexts/PeerProvider.tsx b/src/contexts/PeerProvider.tsx index 6bd4729d..7c71cba9 100644 --- a/src/contexts/PeerProvider.tsx +++ b/src/contexts/PeerProvider.tsx @@ -138,6 +138,7 @@ export default function PeerProvider({ toggleSSH(true)} /> )} diff --git a/src/modules/access-control/AccessControlModal.tsx b/src/modules/access-control/AccessControlModal.tsx index 8bb05a81..54780c8c 100644 --- a/src/modules/access-control/AccessControlModal.tsx +++ b/src/modules/access-control/AccessControlModal.tsx @@ -45,7 +45,7 @@ import React, { useMemo, useState } from "react"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Group } from "@/interfaces/Group"; -import { Policy, Protocol } from "@/interfaces/Policy"; +import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy"; import { PostureCheck } from "@/interfaces/PostureCheck"; import { useAccessControl } from "@/modules/access-control/useAccessControl"; import { PostureCheckTab } from "@/modules/posture-checks/ui/PostureCheckTab"; @@ -116,6 +116,9 @@ type ModalProps = { postureCheckTemplates?: PostureCheck[]; useSave?: boolean; allowEditPeers?: boolean; + initialProtocol?: Protocol; + initialPorts?: number[]; + initialDestinationResource?: PolicyRuleResource; }; export function AccessControlModalContent({ @@ -128,6 +131,9 @@ export function AccessControlModalContent({ initialDestinationGroups, initialName, initialDescription, + initialProtocol, + initialPorts, + initialDestinationResource, }: Readonly) { const { permission } = usePermissions(); @@ -170,6 +176,9 @@ export function AccessControlModalContent({ initialDestinationGroups, initialName, initialDescription, + initialPorts, + initialProtocol, + initialDestinationResource, }); const [tab, setTab] = useState(() => { diff --git a/src/modules/access-control/useAccessControl.ts b/src/modules/access-control/useAccessControl.ts index 312bf781..54ff8977 100644 --- a/src/modules/access-control/useAccessControl.ts +++ b/src/modules/access-control/useAccessControl.ts @@ -6,7 +6,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useSWRConfig } from "swr"; import { usePolicies } from "@/contexts/PoliciesProvider"; 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 useGroupHelper from "@/modules/groups/useGroupHelper"; import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck"; @@ -18,6 +23,9 @@ type Props = { initialDestinationGroups?: Group[] | string[]; initialName?: string; initialDescription?: string; + initialProtocol?: Protocol; + initialPorts?: number[]; + initialDestinationResource?: PolicyRuleResource; }; // TODO add reducer @@ -29,6 +37,9 @@ export const useAccessControl = ({ initialName, initialDescription, onSuccess, + initialProtocol, + initialPorts, + initialDestinationResource, }: Props = {}) => { const { data: allPostureChecks, isLoading: isPostureChecksLoading } = useFetchApi("/posture-checks"); @@ -75,6 +86,7 @@ export const useAccessControl = ({ const [enabled, setEnabled] = useState(policy?.enabled ?? true); const [ports, setPorts] = useState(() => { + if (initialPorts) return initialPorts; if (!firstRule) return []; if (firstRule.ports == undefined) return []; if (firstRule.ports.length > 0) { @@ -93,7 +105,7 @@ export const useAccessControl = ({ }); const [protocol, setProtocol] = useState( - firstRule ? firstRule.protocol : "all", + firstRule ? firstRule.protocol : initialProtocol ?? "all", ); const [direction, setDirection] = useState(() => { if (!firstRule) return "bi"; @@ -131,7 +143,7 @@ export const useAccessControl = ({ ); const [destinationResource, setDestinationResource] = useState( - firstRule?.destinationResource, + firstRule?.destinationResource ?? initialDestinationResource, ); const { updateOrCreateAndNotify: checkToCreate } = usePostureCheck({}); diff --git a/src/modules/peer/PeerSSHInstructions.tsx b/src/modules/peer/PeerSSHInstructions.tsx index 8177edf1..afb9bfba 100644 --- a/src/modules/peer/PeerSSHInstructions.tsx +++ b/src/modules/peer/PeerSSHInstructions.tsx @@ -9,26 +9,37 @@ import { } from "@components/modal/Modal"; import ModalHeader from "@components/modal/ModalHeader"; import Paragraph from "@components/Paragraph"; +import { SegmentedTabs } from "@components/SegmentedTabs"; 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, TerminalSquare } from "lucide-react"; +import { ExternalLinkIcon, PlusCircle, TerminalSquare } from "lucide-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 { Peer } from "@/interfaces/Peer"; +import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal"; +import { Terminal } from "@/modules/remote-access/ssh/Terminal"; type Props = { open?: boolean; onOpenChange?: (open: boolean) => void; onSuccess?: () => void; + peer?: Peer; }; export const PeerSSHInstructions = ({ open, onOpenChange, onSuccess, + peer, }: Props) => { + const [client, setClient] = useState("cli"); + const [policyModal, setPolicyModal] = useState(false); + return ( } title={"Enable SSH Access"} description={ - "Allow remote SSH access to this machine from other connected network participants." + "Allow remote SSH access from other connected network participants." } color={"netbird"} /> -
+
+ + + + + CLI + + + + Desktop Client + + + + - -

- If you are using NetBird via CLI, you can enable SSH by running -

- - {`netbird down # if NetBird is already running`} - - - {`netbird up --allow-server-ssh --enable-ssh-root`} - -
+ {client === "cli" ? ( + +

+ If you are using NetBird via CLI, you can enable SSH by + running +

+ + {`netbird down # if NetBird is already running`} + + + {`netbird up --allow-server-ssh --enable-ssh-root`} + +
+ ) : ( + +

+ If you are using NetBird via the Desktop Client, click on the + NetBird tray icon, go to Settings and click{" "} + Allow SSH. If you want to enable Root Login go to{" "} + Settings > Advanced Settings and enable SSH + Root Login under the SSH tab. +

+ +
+ )}

- If you are using NetBird via the Desktop Client, click on the - NetBird tray icon, go to Settings and click{" "} - Allow SSH
+ Starting from NetBird v0.60.0, SSH requires an explicit access + control policy that allows TCP traffic on port{" "} + 22

- +
+ +
-

Once the NetBird SSH server is allowed on the client,
@@ -96,15 +141,17 @@ export const PeerSSHInstructions = ({ -

+ + ); diff --git a/src/modules/peer/PeerSSHPolicyInfo.tsx b/src/modules/peer/PeerSSHPolicyInfo.tsx new file mode 100644 index 00000000..38588edc --- /dev/null +++ b/src/modules/peer/PeerSSHPolicyInfo.tsx @@ -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 && ( + <> + + + Starting from NetBird v0.60.0, SSH requires an explicit access + control policy that allows TCP traffic on port 22.{" "} + setPolicyModal(true)}> + Create SSH Policy + + + + + + ) + ); +}; diff --git a/src/modules/peer/PeerSSHPolicyModal.tsx b/src/modules/peer/PeerSSHPolicyModal.tsx new file mode 100644 index 00000000..b11648c2 --- /dev/null +++ b/src/modules/peer/PeerSSHPolicyModal.tsx @@ -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 ( + + { + onOpenChange(false); + }} + /> + + ); +}; diff --git a/src/modules/peer/PeerSSHToggle.tsx b/src/modules/peer/PeerSSHToggle.tsx index 38bf2c4f..5dc493b6 100644 --- a/src/modules/peer/PeerSSHToggle.tsx +++ b/src/modules/peer/PeerSSHToggle.tsx @@ -4,6 +4,7 @@ import { LockIcon, TerminalSquare } from "lucide-react"; import * as React from "react"; import { usePeer } from "@/contexts/PeerProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { PeerSSHPolicyInfo } from "@/modules/peer/PeerSSHPolicyInfo"; export const PeerSSHToggle = () => { const { permission } = usePermissions(); @@ -42,6 +43,7 @@ export const PeerSSHToggle = () => { } /> + ); }; diff --git a/src/modules/peer/usePeerSSHPolicyCheck.ts b/src/modules/peer/usePeerSSHPolicyCheck.ts new file mode 100644 index 00000000..ad385f86 --- /dev/null +++ b/src/modules/peer/usePeerSSHPolicyCheck.ts @@ -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( + "/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, + }; +};