Compare commits

...
Author SHA1 Message Date
Viktor Liu 9a3fc271da Add SSH support for Android peers 2026-09-07 16:27:59 +02:00
13 changed files with 166 additions and 158 deletions

No files matched your search

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

Before

Width:  |  Height:  |  Size: 290 KiB

+55 -36
View File
@@ -11,19 +11,19 @@ import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph"; import Paragraph from "@components/Paragraph";
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 { Mark } from "@components/ui/Mark"; import { Mark } from "@components/ui/Mark";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
import { ExternalLinkIcon, PlusCircle, 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 { useState } from "react";
import sshImage from "@/assets/ssh/ssh-client.png";
import { SegmentedTabs } from "@components/SegmentedTabs"; import { SegmentedTabs } from "@components/SegmentedTabs";
import AndroidIcon from "@/assets/icons/AndroidIcon";
import NetBirdIcon from "@/assets/icons/NetBirdIcon"; 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 { Peer } from "@/interfaces/Peer";
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal"; import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
type Props = { type Props = {
open?: boolean; open?: boolean;
@@ -39,6 +39,11 @@ export const PeerSSHInstructions = ({
peer, peer,
}: Props) => { }: Props) => {
const [client, setClient] = useState("cli"); 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); const [policyModal, setPolicyModal] = useState(false);
// Enabling the SSH server and root login require root, or an administrator on // Enabling the SSH server and root login require root, or an administrator on
@@ -75,46 +80,60 @@ export const PeerSSHInstructions = ({
<NetBirdIcon size={16} /> <NetBirdIcon size={16} />
Desktop Client Desktop Client
</SegmentedTabs.Trigger> </SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"android"}>
<AndroidIcon size={16} className={"fill-nb-gray-400"} />
Android
</SegmentedTabs.Trigger>
</SegmentedTabs.List> </SegmentedTabs.List>
</SegmentedTabs> </SegmentedTabs>
<Steps> <Steps>
{client === "cli" ? ( <Steps.Step step={1}>
<Steps.Step step={1}> {client === "cli" && (
<p className={"font-normal"}> <>
If you are using NetBird via CLI, you can enable SSH by <p className={"font-normal"}>
running{" "} If you are using NetBird via CLI, you can enable SSH by
{isWindows running{" "}
? "these commands in an elevated prompt" {isWindows
: "these commands as root"} ? "these commands in an elevated prompt"
. Run the first one only if NetBird is already running. On a : "these commands as root"}
machine where you do not have those rights, an administrator . Run the first one only if NetBird is already running. On a
has to run them. machine where you do not have those rights, an administrator
</p> has to run them.
<Code codeToCopy={`${prefix}netbird down`}> </p>
<Code.Line>{`${prefix}netbird down`}</Code.Line> <Code codeToCopy={`${prefix}netbird down`}>
</Code> <Code.Line>{`${prefix}netbird down`}</Code.Line>
<Code> </Code>
<Code.Line>{`${prefix}netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line> <Code>
</Code> <Code.Line>{`${prefix}netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line>
</Steps.Step> </Code>
) : ( </>
<Steps.Step step={1}> )}
{client === "gui" && (
<p className={"font-normal"}> <p className={"font-normal"}>
If you are using NetBird via the Desktop Client, click on the If you are using NetBird via the Desktop Client, click on the
NetBird tray icon, go to <Mark>Settings</Mark> and click{" "} NetBird tray icon, open <Mark>Settings</Mark> and turn on{" "}
<Mark>Allow SSH</Mark>. If you want to enable Root Login go to{" "} <Mark>Enable SSH Server</Mark> on the <Mark>SSH</Mark> tab. To
<Mark>Settings &gt; Advanced Settings</Mark> and enable SSH log in as {isWindows ? "an administrator" : "root"}, enable{" "}
Root Login under the SSH tab. <Mark>Allow Root Login</Mark> on the same tab.
</p> </p>
<Lightbox image={sshImage} /> )}
</Steps.Step>
)} {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>
<Steps.Step step={2}> <Steps.Step step={2}>
<p className={"font-normal"}> <p className={"font-normal"}>
Starting from NetBird v0.61.0, SSH requires an explicit access {peerRequiresSSHPolicy
control policy to allow SSH connections to this machine. ? "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> </p>
<div className={"mt-2"}> <div className={"mt-2"}>
<Button <Button
@@ -128,8 +147,8 @@ export const PeerSSHInstructions = ({
</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 enabled on the client, <br />
click <Mark>Confirm & Enable</Mark> below to finish the setup. click <Mark>Finish Setup</Mark> below to complete the setup.
</p> </p>
</Steps.Step> </Steps.Step>
</Steps> </Steps>
+2 -2
View File
@@ -1,7 +1,7 @@
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { Peer } from "@/interfaces/Peer"; import { Peer } from "@/interfaces/Peer";
import { Policy } from "@/interfaces/Policy"; import { Policy } from "@/interfaces/Policy";
import { isNativeSSHSupported } from "@utils/version"; import { usesStandardSSHPort } from "@utils/version";
export const usePeerSSHPolicyCheck = (peer?: Peer) => { export const usePeerSSHPolicyCheck = (peer?: Peer) => {
const { data: policies, isLoading } = useFetchApi<Policy[]>( const { data: policies, isLoading } = useFetchApi<Policy[]>(
@@ -66,7 +66,7 @@ export const usePeerSSHPolicyCheck = (peer?: Peer) => {
!hasSSHPolicy && !hasSSHPolicy &&
!isLoading && !isLoading &&
!!peer?.ssh_enabled && !!peer?.ssh_enabled &&
isNativeSSHSupported(peer.version); usesStandardSSHPort(peer.version);
return { return {
peerPolicies, peerPolicies,
+6 -13
View File
@@ -8,7 +8,6 @@ import {
} from "@components/DropdownMenu"; } from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip"; import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification"; import { notify } from "@components/Notification";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { import {
CheckCircle2, CheckCircle2,
@@ -27,9 +26,9 @@ import { useSWRConfig } from "swr";
import { useBypass, useBypassedPeers } from "@/cloud/edr/useBypass"; import { useBypass, useBypassedPeers } from "@/cloud/edr/useBypass";
import { usePeer } from "@/contexts/PeerProvider"; import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton"; import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
import { useIntegrations } from "@/modules/integrations/edr/useIntegrations"; import { useIntegrations } from "@/modules/integrations/edr/useIntegrations";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton"; import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton"; import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import InlineLink from "@components/InlineLink"; import InlineLink from "@components/InlineLink";
@@ -126,21 +125,15 @@ export default function PeerActionCell() {
const showRevokeBypass = isBypassed && canBypass; const showRevokeBypass = isBypassed && canBypass;
const showApprovalGroup = showApprove || showBypass || showRevokeBypass; const showApprovalGroup = showApprove || showBypass || showRevokeBypass;
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
const showSSHButton = useMemo(() => { const showSSHButton = useMemo(() => {
if (!isSSHSupported) return false;
const isClientSSHEnabled = peer?.local_flags?.server_ssh_allowed; const isClientSSHEnabled = peer?.local_flags?.server_ssh_allowed;
const isDashboardSSHEnabled = peer?.ssh_enabled; const isDashboardSSHEnabled = peer?.ssh_enabled;
if (isDashboardSSHEnabled) return true; if (isDashboardSSHEnabled) return true;
return !isClientSSHEnabled; return !isClientSSHEnabled;
}, [peer]); }, [peer, isSSHSupported]);
// 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 toggleLoginExpiration = async () => {
const text = peer.login_expiration_enabled ? "disabled" : "enabled"; const text = peer.login_expiration_enabled ? "disabled" : "enabled";
@@ -255,7 +248,7 @@ export default function PeerActionCell() {
</> </>
)} )}
{showRemoteAccessItems && ( {peer.connected && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<SSHButton peer={peer} isDropdown={true} /> <SSHButton peer={peer} isDropdown={true} />
-6
View File
@@ -4,11 +4,9 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@components/DropdownMenu"; } from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip"; import FullTooltip from "@components/FullTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconChevronDown } from "@tabler/icons-react"; import { IconChevronDown } from "@tabler/icons-react";
import * as React from "react"; import * as React from "react";
import { usePeer } from "@/contexts/PeerProvider"; import { usePeer } from "@/contexts/PeerProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton"; import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton"; import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { cn } from "@utils/helpers"; import { cn } from "@utils/helpers";
@@ -16,10 +14,6 @@ import { cn } from "@utils/helpers";
export const PeerConnectButton = () => { export const PeerConnectButton = () => {
const { peer } = usePeer(); const { peer } = usePeer();
const isConnected = peer.connected; const isConnected = peer.connected;
const os = getOperatingSystem(peer?.os);
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
if (isMobile) return;
return isConnected ? ( return isConnected ? (
<> <>
+12
View File
@@ -0,0 +1,12 @@
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,10 +1,8 @@
import Button from "@components/Button"; import Button from "@components/Button";
import { DropdownMenuItem } from "@components/DropdownMenu"; import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, MonitorIcon } from "lucide-react"; import { CircleHelpIcon, MonitorIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer"; import { Peer } from "@/interfaces/Peer";
import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip"; import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
@@ -13,18 +11,9 @@ type Props = {
isDropdown?: boolean; isDropdown?: boolean;
}; };
const RDP_SUPPORTED_OS = new Set([
OperatingSystem.WINDOWS,
OperatingSystem.LINUX,
OperatingSystem.FREEBSD,
]);
export const RDPButton = ({ peer, isDropdown = false }: Props) => { export const RDPButton = ({ peer, isDropdown = false }: Props) => {
const { permission } = usePermissions(); const { permission } = usePermissions();
const os = getOperatingSystem(peer?.os);
if (!RDP_SUPPORTED_OS.has(os)) return null;
const disabled = !peer.connected || !permission.peers.update; const disabled = !peer.connected || !permission.peers.update;
const hasPermission = permission.peers.update; const hasPermission = permission.peers.update;
+38 -46
View File
@@ -7,8 +7,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Peer } from "@/interfaces/Peer"; import { Peer } from "@/interfaces/Peer";
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal"; import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip"; import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
type Props = { type Props = {
peer: Peer; peer: Peer;
@@ -25,52 +24,45 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
const hasPermission = permission.peers.update; const hasPermission = permission.peers.update;
const os = getOperatingSystem(peer?.os); if (!isSSHSupportedOnOS(peer?.os)) return null;
const isSSHSupported = os !== OperatingSystem.IOS;
return ( return (
isSSHSupported && ( <>
<> {modal && (
{modal && ( <SSHCredentialsModal open={modal} onOpenChange={setModal} peer={peer} />
<SSHCredentialsModal )}
open={modal} <div>
onOpenChange={setModal} <SSHTooltip
peer={peer} isOnline={peer.connected}
/> isSSHEnabled={isSSHEnabled}
)} hasPermission={hasPermission}
<div> side={isDropdown ? "left" : "top"}
<SSHTooltip >
isOnline={peer.connected} {isDropdown ? (
isSSHEnabled={isSSHEnabled} <DropdownMenuItem
hasPermission={hasPermission} onClick={() => setModal(true)}
side={isDropdown ? "left" : "top"} disabled={disabled}
> className={"w-full"}
{isDropdown ? ( >
<DropdownMenuItem <div className={"flex gap-3 items-center w-full"}>
onClick={() => setModal(true)} <TerminalIcon size={14} className={"shrink-0"} />
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 SSH
{disabled && <CircleHelpIcon size={12} />} </div>
</Button> </DropdownMenuItem>
)} ) : (
</SSHTooltip> <Button
</div> variant="secondary"
</> size="sm"
) onClick={() => setModal(true)}
disabled={disabled}
>
<TerminalIcon size={16} />
SSH
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</SSHTooltip>
</div>
</>
); );
}; };
@@ -13,7 +13,7 @@ import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph"; import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator"; import Separator from "@components/Separator";
import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { isNativeSSHSupported } from "@utils/version"; import { usesStandardSSHPort } from "@utils/version";
import { import {
ChevronsLeftRightEllipsis, ChevronsLeftRightEllipsis,
ExternalLinkIcon, ExternalLinkIcon,
@@ -39,7 +39,7 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
: "root", : "root",
); );
const initialPort = isNativeSSHSupported(peer.version) ? "22" : "44338"; const initialPort = usesStandardSSHPort(peer.version) ? "22" : "44338";
const [port, setPort] = useState(initialPort); const [port, setPort] = useState(initialPort);
const userNameError = useMemo(() => { const userNameError = useMemo(() => {
+15 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { compareVersions, isNativeSSHSupported, isNewerVersion } from "./version"; import { compareVersions, isNewerVersion, usesStandardSSHPort } from "./version";
describe("isNativeSSHSupported", () => { describe("usesStandardSSHPort", () => {
it.each([ it.each([
{ version: "v0.59.9", shouldSupport: false, desc: "below minimum" }, { version: "v0.59.9", shouldSupport: false, desc: "below minimum" },
{ version: "v0.59.10", shouldSupport: false, desc: "below minimum" }, { version: "v0.59.10", shouldSupport: false, desc: "below minimum" },
@@ -36,8 +36,14 @@ describe("isNativeSSHSupported", () => {
shouldSupport: true, shouldSupport: true,
desc: "CI build suffix", 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 }) => { ])("$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, expected: false,
desc: "development build", 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: "0.77.0", latest: "", expected: false, desc: "unknown latest" },
])("$current → $latest → $expected ($desc)", ({ current, latest, expected }) => { ])("$current → $latest → $expected ($desc)", ({ current, latest, expected }) => {
expect(isNewerVersion(current, latest)).toBe(expected); expect(isNewerVersion(current, latest)).toBe(expected);
+26 -34
View File
@@ -62,6 +62,17 @@ const releaseParts = (version: string): number[] =>
return Number.isNaN(parsed) ? 0 : parsed; 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. * Compare semantic versions.
* Returns true if version >= minVersion. * Returns true if version >= minVersion.
@@ -84,37 +95,17 @@ export const compareVersions = (
return true; 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. * Returns true when `latest` is a strictly newer release than `current` — i.e.
* an update is available. Only release components decide: an enterprise build * 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, * ("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. * 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 => { export const isNewerVersion = (current: string, latest: string): boolean => {
if (!current || !latest) return false; if (!hasReleaseVersion(current) || !hasReleaseVersion(latest)) return false;
if (isDevelopmentVersion(current) || isDevelopmentVersion(latest)) {
return false;
}
const currentParts = releaseParts(current); const currentParts = releaseParts(current);
const latestParts = releaseParts(latest); const latestParts = releaseParts(latest);
@@ -139,26 +130,27 @@ export const isNewerVersion = (current: string, latest: string): boolean => {
export const isRoutingPeerSupported = (version: string, os: string) => { export const isRoutingPeerSupported = (version: string, os: string) => {
const operatingSystem = getOperatingSystem(os); const operatingSystem = getOperatingSystem(os);
if (operatingSystem == OperatingSystem.LINUX) return true; if (operatingSystem == OperatingSystem.LINUX) return true;
if (version == "development") return true; if (!hasReleaseVersion(version)) return true;
return compareVersions(version, "0.36.6"); return compareVersions(version, "0.36.6");
}; };
/** /**
* Check if native SSH is supported. * Check if the peer's SSH server listens on the standard port 22, which it does
* Supported starting from NetBird v0.60.0+. * 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 * @param version
*/ */
export const isNativeSSHSupported = (version: string) => { export const usesStandardSSHPort = (version: string) =>
if (version == "development") return true; !hasReleaseVersion(version) || compareVersions(version, "0.60.0");
return compareVersions(version, "0.60.0");
};
/** /**
* Check if NetBird SSH protocol is supported. * Check if NetBird SSH protocol is supported.
* Supported starting from NetBird v0.61.0+. * Supported starting from NetBird v0.61.0+.
* @param version * @param version
*/ */
export const isNetbirdSSHProtocolSupported = (version: string) => { export const isNetbirdSSHProtocolSupported = (version: string) =>
if (version == "development") return true; !hasReleaseVersion(version) || compareVersions(version, "0.61.0");
return compareVersions(version, "0.61.0");
};