diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index 54c4eb9d7..7326741d2 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -70,6 +70,7 @@ 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"; @@ -430,6 +431,7 @@ const PeerOverviewTabContent = () => { const { peer } = usePeer(); const { permission } = usePermissions(); const { selectedGroups, setSelectedGroups } = usePeerSettings(); + const isSSHSupported = isSSHSupportedOnOS(peer?.os); return (
@@ -458,11 +460,14 @@ const PeerOverviewTabContent = () => {
)} - + {isSSHSupported && }
- Connect directly to this peer via SSH or RDP. + + Connect directly to this peer via{" "} + {isSSHSupported ? "SSH or RDP" : "RDP"}. +
diff --git a/src/app/(remote-access)/peer/ssh/page.tsx b/src/app/(remote-access)/peer/ssh/page.tsx index ef8c7eb3c..58d3b105f 100644 --- a/src/app/(remote-access)/peer/ssh/page.tsx +++ b/src/app/(remote-access)/peer/ssh/page.tsx @@ -13,7 +13,7 @@ import { useNetBirdClient, } from "@/modules/remote-access/useNetBirdClient"; import { - isNativeSSHSupported, + usesStandardSSHPort, 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 = isNativeSSHSupported(peer.version) ? "22022" : port; + const aclPort = usesStandardSSHPort(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 = isNativeSSHSupported(peer.version) ? "22022" : port; + const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port; const protocol = isNetbirdSSHProtocolSupported(peer.version) ? "netbird-ssh" : "tcp"; diff --git a/src/assets/ssh/ssh-client.png b/src/assets/ssh/ssh-client.png deleted file mode 100644 index 2d4bf1a2f..000000000 Binary files a/src/assets/ssh/ssh-client.png and /dev/null differ diff --git a/src/modules/peer/PeerSSHInstructions.tsx b/src/modules/peer/PeerSSHInstructions.tsx index 1bc053a94..5442ca36b 100644 --- a/src/modules/peer/PeerSSHInstructions.tsx +++ b/src/modules/peer/PeerSSHInstructions.tsx @@ -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 { Peer } from "@/interfaces/Peer"; -import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal"; 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"; type Props = { open?: boolean; @@ -39,6 +39,11 @@ 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 @@ -75,46 +80,60 @@ export const PeerSSHInstructions = ({ Desktop Client + + + Android + - {client === "cli" ? ( - + + {client === "cli" && ( + <> +

+ 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. +

+ + {`${prefix}netbird down`} + + + {`${prefix}netbird up --allow-server-ssh --enable-ssh-root`} + + + )} + + {client === "gui" && (

- 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. + If you are using NetBird via the Desktop Client, click on the + NetBird tray icon, open Settings and turn on{" "} + Enable SSH Server on the SSH tab. To + log in as {isWindows ? "an administrator" : "root"}, enable{" "} + Allow Root Login on the same tab.

- - {`${prefix}netbird down`} - - - {`${prefix}netbird up --allow-server-ssh --enable-ssh-root`} - -
- ) : ( - + )} + + {client === "android" && (

- 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 on Android, open the app, tap{" "} + Settings, then Advanced and turn on{" "} + Enable SSH Server.

- -
- )} + )} +

- Starting from NetBird v0.61.0, SSH requires an explicit access - control policy to allow SSH connections to this machine. + {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."}

- )} - -
- - ) +
+ + ) : ( + + )} + +
+ ); }; diff --git a/src/modules/remote-access/ssh/SSHCredentialsModal.tsx b/src/modules/remote-access/ssh/SSHCredentialsModal.tsx index 044ea80cc..684d1dae9 100644 --- a/src/modules/remote-access/ssh/SSHCredentialsModal.tsx +++ b/src/modules/remote-access/ssh/SSHCredentialsModal.tsx @@ -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 { isNativeSSHSupported } from "@utils/version"; +import { usesStandardSSHPort } from "@utils/version"; import { ChevronsLeftRightEllipsis, ExternalLinkIcon, @@ -39,7 +39,7 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => { : "root", ); - const initialPort = isNativeSSHSupported(peer.version) ? "22" : "44338"; + const initialPort = usesStandardSSHPort(peer.version) ? "22" : "44338"; const [port, setPort] = useState(initialPort); const userNameError = useMemo(() => { diff --git a/src/utils/version.test.ts b/src/utils/version.test.ts index 6641a25a4..cd8051996 100644 --- a/src/utils/version.test.ts +++ b/src/utils/version.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { compareVersions, isNativeSSHSupported, isNewerVersion } from "./version"; +import { compareVersions, isNewerVersion, usesStandardSSHPort } from "./version"; -describe("isNativeSSHSupported", () => { +describe("usesStandardSSHPort", () => { it.each([ { version: "v0.59.9", shouldSupport: false, desc: "below minimum" }, { version: "v0.59.10", shouldSupport: false, desc: "below minimum" }, @@ -36,8 +36,14 @@ describe("isNativeSSHSupported", () => { 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(isNativeSSHSupported(version)).toBe(shouldSupport); + expect(usesStandardSSHPort(version)).toBe(shouldSupport); }); }); @@ -108,6 +114,12 @@ 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); diff --git a/src/utils/version.ts b/src/utils/version.ts index f305f1365..ae2ac4535 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -62,6 +62,17 @@ const releaseParts = (version: string): number[] => return Number.isNaN(parsed) ? 0 : parsed; }); +/** + * Check if a version string carries a numeric release to compare against. + * + * Builds made outside the release pipeline don't: the "development" tag of the + * Go builds, and the commit-stamped builds the mobile clients report + * ("dev-16f7e1e14"). Neither does a peer that reports no version at all. + * Feature gates treat all of them as current rather than as release 0. + */ +export const hasReleaseVersion = (version: string): boolean => + /^v?\d/i.test(version?.trim() ?? ""); + /** * Compare semantic versions. * Returns true if version >= minVersion. @@ -84,37 +95,17 @@ export const compareVersions = ( return true; }; -/** - * Whether a version string names a non-release build. Mirrors the management - * server's version.IsDevelopmentVersion: the literal "development" plus the - * "ci-" and "dev-" prefixes it stamps on snapshot builds ("ci-7470fbdd"). - * - * Such a string carries no release to compare against. releaseParts() reads - * its leading word as 0, so without this check every snapshot install would - * see the current release as newer and nag about an update forever. - */ -export const isDevelopmentVersion = (version: string): boolean => { - const bare = version.trim().replace(/^v/i, ""); - return ( - bare.startsWith("development") || - bare.startsWith("ci-") || - bare.startsWith("dev-") - ); -}; - /** * Returns true when `latest` is a strictly newer release than `current` — i.e. * an update is available. Only release components decide: an enterprise build * ("0.77.0+enterprise.1") is up to date against the "0.77.0" it was built from, * matching how the management server evaluates it server-side. * - * Development and snapshot builds never report an update, in either position. + * Builds without a release ("development", "dev-16f7e1e14", "ci-7470fbdd") + * never report an update, in either position. */ export const isNewerVersion = (current: string, latest: string): boolean => { - if (!current || !latest) return false; - if (isDevelopmentVersion(current) || isDevelopmentVersion(latest)) { - return false; - } + if (!hasReleaseVersion(current) || !hasReleaseVersion(latest)) return false; const currentParts = releaseParts(current); const latestParts = releaseParts(latest); @@ -139,26 +130,27 @@ export const isNewerVersion = (current: string, latest: string): boolean => { export const isRoutingPeerSupported = (version: string, os: string) => { const operatingSystem = getOperatingSystem(os); if (operatingSystem == OperatingSystem.LINUX) return true; - if (version == "development") return true; + if (!hasReleaseVersion(version)) return true; return compareVersions(version, "0.36.6"); }; /** - * Check if native SSH is supported. - * Supported starting from NetBird v0.60.0+. + * Check if the peer's SSH server listens on the standard port 22, which it does + * starting from NetBird v0.60.0+. Older clients listen on 44338 instead. + * Access control rules address such a server on 22022, and a legacy one on + * whichever port the connection uses. + * + * Only a version that reports a release older than 0.60.0 is treated as legacy, + * so development and unreported versions get the current behaviour. * @param version */ -export const isNativeSSHSupported = (version: string) => { - if (version == "development") return true; - return compareVersions(version, "0.60.0"); -}; +export const usesStandardSSHPort = (version: string) => + !hasReleaseVersion(version) || compareVersions(version, "0.60.0"); /** * Check if NetBird SSH protocol is supported. * Supported starting from NetBird v0.61.0+. * @param version */ -export const isNetbirdSSHProtocolSupported = (version: string) => { - if (version == "development") return true; - return compareVersions(version, "0.61.0"); -}; +export const isNetbirdSSHProtocolSupported = (version: string) => + !hasReleaseVersion(version) || compareVersions(version, "0.61.0");