Compare commits

...
Author SHA1 Message Date
Viktor Liu 9a3fc271da Add SSH support for Android peers 2026-09-07 16:27:59 +02:00
Maycon Santos cba2613e68 Add anonymization level and upload URL to the debug bundle modal (#753)
Surfaces the two bundle parameters added to the management API: an
anonymization level (Default/Strict, shown when anonymization is enabled)
and an optional upload service URL that overrides the default upload
server. The upload URL is validated as an https URL before the job can be
submitted; both fields are omitted from the request when left at their
defaults so the peer applies its own.
2026-09-03 20:05:02 +02:00
Maycon Santos fe32179dc1 Pin the group view's group, and give agentgateway a reachable upstream (#788)
Stabilize the flaky Control Center and agentgateway e2e specs (#788)

  Three separate causes behind the recent e2e failures.

  The group view ranked every group in the account by peer count to decide what
  to display, so with two workers it could land on a group another spec had just
  created. openGroupView() selects the group by name instead.

  Management now checks a provider's upstream URL when the provider is saved and
  rejects it unless the URL returns a model list, so the agentgateway spec's
  placeholder host stopped working. The test environment runs a stub that answers
  the check.

  The Backspace test blurred the group panel's search input before that input had
  taken focus, so it reclaimed focus and swallowed the key.
2026-09-03 18:38:12 +02:00
Eduard Gert 1bad86b277 Show peer-sourced policies in the Control Center peer view (#786)
The peer view matched policies only by source group membership, so a
policy whose source is the peer itself never appeared in that peer's
view. There was no group fallback either: a rule carrying a
sourceResource has its sources nulled on save, so the group check could
only ever return false for exactly these policies.

Such policies are reachable from the canvas itself, since dragging a
connection off a peer node writes a sourceResource rather than a group.
A user could create a peer-sourced policy, deploy it, drill into that
same peer and not see it.

Match rule.sourceResource against the peer id alongside the group check.
The rendering side needs no change, as select-peer-node is the peer and
its edge to the policy is drawn unconditionally. Draft mode is fixed
transitively: useDraft harvests visible policies from the live view's
nodes, and already draws a sourceResource peer as a source card.
2026-09-03 14:29:06 +02:00
24 changed files with 341 additions and 188 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+35 -1
View File
@@ -532,7 +532,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,6 +540,7 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -612,6 +613,30 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() {
cat <<EOF
{
@@ -818,6 +843,15 @@ services:
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
+25
View File
@@ -24,6 +24,31 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
await dismissBlockingOverlays(page);
}
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
@@ -59,7 +59,12 @@ test.describe
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
const upstreamURL = "https://agentgateway.e2e.example";
// Saving a provider makes management call the upstream, and it refuses
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
await page
.getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL);
@@ -364,13 +364,17 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
}) => {
const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1);
// React Flow ignores delete keys while an input is focused, and the panel's
// search takes focus on click, so blur it before pressing Backspace.
await group.click();
await expect(group).toHaveClass(/selected/);
await page.evaluate(
() => (document.activeElement as HTMLElement | null)?.blur(),
);
// React Flow ignores delete keys while an input is focused, and the panel
// the click opens takes focus into its search — from an effect, about
// 100ms later. Blurring before that lands leaves the input to claim focus
// afterwards and swallow the Backspace, so wait for the focus, then drop
// it.
const panelSearch = page.locator("#cc-group-panel input").first();
await expect(panelSearch).toBeFocused();
await panelSearch.blur();
await expect(panelSearch).not.toBeFocused();
await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0);
@@ -19,6 +19,7 @@ import {
dismissBlockingOverlays,
enterDraft,
openControlCenter,
openGroupView,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
@@ -37,7 +38,6 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX);
});
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -49,7 +49,9 @@ test.describe
src.id,
dst.id,
);
await openControlCenter(page, "groups");
// Selected by name: the view's own pick ranks every group in the account,
// so a peer-bearing group from another spec on the other worker wins it.
await openGroupView(page, src.name);
await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
+7 -14
View File
@@ -18,6 +18,7 @@ import {
clickContextMenuItem,
dismissBlockingOverlays,
openControlCenter,
openGroupView,
resetDraftState,
switchFlowView,
} from "../helpers/control-center";
@@ -47,8 +48,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
});
async function seedPolicyAndOpenGroupView(page: Page, enabled = true) {
// The group view auto-selects a group that HAS a policy, so stale policies
// would make the rendered group non-deterministic.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -64,15 +63,10 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
const policyNode = canvasNode(page, `policy-${policy.id}`);
// A stale SWR read or a build/fit race can fail to place the node.
for (let attempt = 0; attempt < 3; attempt++) {
await openControlCenter(page, "groups");
const shown = await policyNode
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (shown) break;
}
// Selecting src by name rather than trusting the view's own pick: the
// auto-select ranks every group in the account, so a peer-bearing group
// another spec seeded on the other worker outranks this one.
await openGroupView(page, src.name);
await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode };
}
@@ -512,7 +506,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
test("Should save group resource membership in live mode (correct payload)", async ({
dashboardAsOwner: page,
}) => {
// Clean slate so the group view deterministically lands on our group.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -529,7 +522,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
[],
);
await openControlCenter(page, "groups");
await openGroupView(page, src.name);
const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 });
@@ -610,7 +603,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openControlCenter(page, "groups");
await openGroupView(page, src.name);
const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect(
+7 -2
View File
@@ -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 (
<div className={"px-8"}>
@@ -458,11 +460,14 @@ const PeerOverviewTabContent = () => {
</div>
)}
<PeerSSHToggle />
{isSSHSupported && <PeerSSHToggle />}
<div>
<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">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
+3 -3
View File
@@ -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";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

+7
View File
@@ -17,7 +17,14 @@ export interface Workload {
// Parameters for bundle job
export interface BundleJobParameters {
anonymize: boolean;
// Anonymization level applied when anonymize is enabled. "default" keeps
// internal IP ranges readable; "strict" also anonymizes them. Omitted or
// empty resolves to the default on the peer.
anonymize_level?: "default" | "strict";
bundle_for: boolean;
bundle_for_time: number;
log_file_count: number;
// Upload service URL the peer requests an upload URL from. Empty selects the
// default upload server.
upload_url?: string;
}
@@ -2,19 +2,19 @@ import { Edge, Node } from "@xyflow/react";
import { sortBy } from "lodash";
import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy";
import { useControlCenterData } from "@/modules/control-center/hooks/useControlCenterData";
import {
addNode,
addEdge,
addNode,
DEFAULT_LAYOUT_CONFIG,
} from "@/modules/control-center/utils/graph-builder";
import { withFreshGroupCounts } from "@/modules/control-center/utils/helpers";
import { applyD3HierarchicalLayout } from "@/modules/control-center/utils/layouts";
import {
addAgentNetworkProviderNodes,
useAgentNetworkOverlay,
} from "./agent-network-overlay";
import { addDestinationResourceNodes, ViewResult } from "./types";
import { useControlCenterData } from "@/modules/control-center/hooks/useControlCenterData";
import { withFreshGroupCounts } from "@/modules/control-center/utils/helpers";
export function usePeerView() {
const { policies, peers, networks, networkResources, groups, isDataReady } =
@@ -39,6 +39,7 @@ export function usePeerView() {
(policiesOverride ?? policies!).filter((p) => {
const rule = p.rules?.[0];
if (!rule) return false;
if (rule.sourceResource?.id === peerId) return true;
const sources = rule.sources as Group[];
return sources?.some((d) => peerGroups?.some((pg) => pg.id === d.id));
}),
@@ -80,7 +81,6 @@ export function usePeerView() {
type: "smart",
data: { enabled, policy },
});
});
addDestinationResourceNodes(
+76 -2
View File
@@ -4,6 +4,7 @@ import {
FileText,
PlusCircle,
Shield,
UploadCloud,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -19,6 +20,13 @@ import {
} from "@/components/modal/Modal";
import ModalHeader from "@/components/modal/ModalHeader";
import { notify } from "@/components/Notification";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/Select";
import Separator from "@/components/Separator";
import { Workload } from "@/interfaces/Job";
import { useApiCall } from "@/utils/api";
@@ -36,10 +44,15 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
const [bundleForTime, setBundleForTime] = useState<string>("");
const [logFileCount, setLogFileCount] = useState<string>("10");
const [anonymize, setAnonymize] = useState<boolean>(false);
const [anonymizeLevel, setAnonymizeLevel] = useState<"default" | "strict">(
"default",
);
const [uploadUrl, setUploadUrl] = useState<string>("");
const isValid = useMemo(() => {
let validBundleFor = true;
let validLogFileCount = true;
let validUploadUrl = true;
const logFileCountNumber = Number(logFileCount);
const bundleForTimeNumber = Number(bundleForTime);
@@ -50,8 +63,18 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
return validLogFileCount && validBundleFor;
}, [bundleForTime, logFileCount]);
const trimmedUploadUrl = uploadUrl.trim();
if (trimmedUploadUrl) {
try {
const parsed = new URL(trimmedUploadUrl);
validUploadUrl = parsed.protocol === "https:" && parsed.host !== "";
} catch {
validUploadUrl = false;
}
}
return validLogFileCount && validBundleFor && validUploadUrl;
}, [bundleForTime, logFileCount, uploadUrl]);
const createDebugJob = async () => {
notify({
@@ -64,11 +87,13 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
type: "bundle",
parameters: {
anonymize,
anonymize_level: anonymize ? anonymizeLevel : undefined,
bundle_for: bundleForTimeEnabled,
bundle_for_time: bundleForTimeEnabled
? Number(bundleForTime)
: undefined,
log_file_count: logFileCount ? Number(logFileCount) : 10,
upload_url: uploadUrl.trim() ? uploadUrl.trim() : undefined,
},
},
})
@@ -172,6 +197,55 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
}
helpText="Remove sensitive information (IP addresses, domains etc.) before creating the debug bundle."
/>
{/* Anonymization Level */}
{anonymize && (
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Anonymization Level</Label>
<HelpText>
Default keeps internal (private) IP ranges readable; Strict also
anonymizes private, CGNAT and link-local addresses.
</HelpText>
</div>
<Select
value={anonymizeLevel}
onValueChange={(v) => setAnonymizeLevel(v as "default" | "strict")}
>
<SelectTrigger className="w-[220px]">
<div className="flex items-center gap-3">
<Shield size={15} className="text-nb-gray-300 shrink-0" />
<SelectValue placeholder="Select level..." />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="strict">Strict</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Upload URL */}
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Upload URL (optional)</Label>
<HelpText>
Service the peer requests an upload URL from. Leave empty to use
the default upload server. Must be an https URL.
</HelpText>
</div>
<Input
type="text"
placeholder={"https://upload.debug.netbird.io"}
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
maxWidthClass="w-[220px]"
customPrefix={<UploadCloud size={16} className="text-nb-gray-300" />}
/>
</div>
</div>
<ModalFooter className="items-center">
+55 -36
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,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 = ({
<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>
{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}>
<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" && (
<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.
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.
</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}>
<p className={"font-normal"}>
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."}
</p>
<div className={"mt-2"}>
<Button
@@ -128,8 +147,8 @@ export const PeerSSHInstructions = ({
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Once the NetBird SSH server is allowed on the client, <br />
click <Mark>Confirm & Enable</Mark> below to finish the setup.
Once the NetBird SSH server is enabled on the client, <br />
click <Mark>Finish Setup</Mark> below to complete 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 { isNativeSSHSupported } from "@utils/version";
import { usesStandardSSHPort } 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 &&
isNativeSSHSupported(peer.version);
usesStandardSSHPort(peer.version);
return {
peerPolicies,
+6 -13
View File
@@ -8,7 +8,6 @@ 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,
@@ -27,9 +26,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";
@@ -126,21 +125,15 @@ 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]);
// 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;
}, [peer, isSSHSupported]);
const toggleLoginExpiration = async () => {
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
@@ -255,7 +248,7 @@ export default function PeerActionCell() {
</>
)}
{showRemoteAccessItems && (
{peer.connected && (
<>
<DropdownMenuSeparator />
<SSHButton peer={peer} isDropdown={true} />
-6
View File
@@ -4,11 +4,9 @@ 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";
@@ -16,10 +14,6 @@ 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 ? (
<>
+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 { 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";
@@ -13,18 +11,9 @@ 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;
Loaded 20 of 24 files, more files were not shown because too many files have changed in this diff. Show more