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
26 changed files with 341 additions and 249 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
# Ignore zitadel environment # Ignore zitadel environment
.env .env
Caddyfile Caddyfile
agentgateway-stub.Caddyfile
management.json management.json
turnserver.conf turnserver.conf
zitadel.env zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose) DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes $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 -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.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 "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:" echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" 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." echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1 exit 1
fi fi
@@ -540,6 +540,7 @@ initEnvironment() {
echo Rendering initial files... echo Rendering initial files...
renderDockerCompose > docker-compose.yml renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env renderZitadelEnv > zitadel.env
echo "" > turnserver.conf echo "" > turnserver.conf
echo "" > management.json echo "" > management.json
@@ -612,6 +613,30 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env 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() { renderCaddyfile() {
cat <<EOF cat <<EOF
{ {
@@ -818,6 +843,15 @@ services:
volumes: volumes:
- netbird_caddy_data:/data - netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile - ./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
management: management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG} 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); 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) { export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page); await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true }); await page.getByTestId(`cc-flow-${view}`).click({ force: true });
@@ -59,7 +59,12 @@ test.describe
.getByTestId("agent-network-provider-option-agentgateway") .getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true }); .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 await page
.getByTestId("agent-network-provider-upstream-url") .getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL); .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); const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1); 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 group.click();
await expect(group).toHaveClass(/selected/); await expect(group).toHaveClass(/selected/);
await page.evaluate( // React Flow ignores delete keys while an input is focused, and the panel
() => (document.activeElement as HTMLElement | null)?.blur(), // 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 page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0); await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0); await expectChangeCount(page, 0);
@@ -19,6 +19,7 @@ import {
dismissBlockingOverlays, dismissBlockingOverlays,
enterDraft, enterDraft,
openControlCenter, openControlCenter,
openGroupView,
resetDraftState, resetDraftState,
reviewButton, reviewButton,
} from "../helpers/control-center"; } from "../helpers/control-center";
@@ -37,7 +38,6 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX); await deleteGroupsByPrefix(page, PREFIX);
}); });
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) { async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX); await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX); await deleteGroupsByPrefix(page, PREFIX);
@@ -49,7 +49,9 @@ test.describe
src.id, src.id,
dst.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); await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`); const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 }); await expect(policyNode).toBeVisible({ timeout: 15_000 });
+7 -14
View File
@@ -18,6 +18,7 @@ import {
clickContextMenuItem, clickContextMenuItem,
dismissBlockingOverlays, dismissBlockingOverlays,
openControlCenter, openControlCenter,
openGroupView,
resetDraftState, resetDraftState,
switchFlowView, switchFlowView,
} from "../helpers/control-center"; } from "../helpers/control-center";
@@ -47,8 +48,6 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
}); });
async function seedPolicyAndOpenGroupView(page: Page, enabled = true) { 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 deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(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}`); const policyNode = canvasNode(page, `policy-${policy.id}`);
// A stale SWR read or a build/fit race can fail to place the node. // Selecting src by name rather than trusting the view's own pick: the
for (let attempt = 0; attempt < 3; attempt++) { // auto-select ranks every group in the account, so a peer-bearing group
await openControlCenter(page, "groups"); // another spec seeded on the other worker outranks this one.
const shown = await policyNode await openGroupView(page, src.name);
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (shown) break;
}
await expect(policyNode).toBeVisible({ timeout: 10_000 }); await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode }; 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 ({ test("Should save group resource membership in live mode (correct payload)", async ({
dashboardAsOwner: page, dashboardAsOwner: page,
}) => { }) => {
// Clean slate so the group view deterministically lands on our group.
await deletePoliciesBySubstring(page, PREFIX); await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(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}`); const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 }); 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 createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openControlCenter(page, "groups"); await openGroupView(page, src.name);
const policyNode = canvasNode(page, `policy-${p1.id}`); const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 }); await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect( await expect(
+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

-1
View File
@@ -35,7 +35,6 @@ export interface Account {
local_mfa_enabled?: boolean; local_mfa_enabled?: boolean;
ipv6_enabled_groups?: string[]; ipv6_enabled_groups?: string[];
network_range_v6?: string; network_range_v6?: string;
debug_bundle_upload_url?: string;
agent_network_only?: boolean; agent_network_only?: boolean;
dashboard_features?: AccountDashboardFeatures; dashboard_features?: AccountDashboardFeatures;
}; };
+7
View File
@@ -17,7 +17,14 @@ export interface Workload {
// Parameters for bundle job // Parameters for bundle job
export interface BundleJobParameters { export interface BundleJobParameters {
anonymize: boolean; 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: boolean;
bundle_for_time: number; bundle_for_time: number;
log_file_count: 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 { sortBy } from "lodash";
import { Group } from "@/interfaces/Group"; import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy"; import { Policy } from "@/interfaces/Policy";
import { useControlCenterData } from "@/modules/control-center/hooks/useControlCenterData";
import { import {
addNode,
addEdge, addEdge,
addNode,
DEFAULT_LAYOUT_CONFIG, DEFAULT_LAYOUT_CONFIG,
} from "@/modules/control-center/utils/graph-builder"; } from "@/modules/control-center/utils/graph-builder";
import { withFreshGroupCounts } from "@/modules/control-center/utils/helpers";
import { applyD3HierarchicalLayout } from "@/modules/control-center/utils/layouts"; import { applyD3HierarchicalLayout } from "@/modules/control-center/utils/layouts";
import { import {
addAgentNetworkProviderNodes, addAgentNetworkProviderNodes,
useAgentNetworkOverlay, useAgentNetworkOverlay,
} from "./agent-network-overlay"; } from "./agent-network-overlay";
import { addDestinationResourceNodes, ViewResult } from "./types"; import { addDestinationResourceNodes, ViewResult } from "./types";
import { useControlCenterData } from "@/modules/control-center/hooks/useControlCenterData";
import { withFreshGroupCounts } from "@/modules/control-center/utils/helpers";
export function usePeerView() { export function usePeerView() {
const { policies, peers, networks, networkResources, groups, isDataReady } = const { policies, peers, networks, networkResources, groups, isDataReady } =
@@ -39,6 +39,7 @@ export function usePeerView() {
(policiesOverride ?? policies!).filter((p) => { (policiesOverride ?? policies!).filter((p) => {
const rule = p.rules?.[0]; const rule = p.rules?.[0];
if (!rule) return false; if (!rule) return false;
if (rule.sourceResource?.id === peerId) return true;
const sources = rule.sources as Group[]; const sources = rule.sources as Group[];
return sources?.some((d) => peerGroups?.some((pg) => pg.id === d.id)); return sources?.some((d) => peerGroups?.some((pg) => pg.id === d.id));
}), }),
@@ -80,7 +81,6 @@ export function usePeerView() {
type: "smart", type: "smart",
data: { enabled, policy }, data: { enabled, policy },
}); });
}); });
addDestinationResourceNodes( addDestinationResourceNodes(
+76 -2
View File
@@ -4,6 +4,7 @@ import {
FileText, FileText,
PlusCircle, PlusCircle,
Shield, Shield,
UploadCloud,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
@@ -19,6 +20,13 @@ import {
} from "@/components/modal/Modal"; } from "@/components/modal/Modal";
import ModalHeader from "@/components/modal/ModalHeader"; import ModalHeader from "@/components/modal/ModalHeader";
import { notify } from "@/components/Notification"; import { notify } from "@/components/Notification";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/Select";
import Separator from "@/components/Separator"; import Separator from "@/components/Separator";
import { Workload } from "@/interfaces/Job"; import { Workload } from "@/interfaces/Job";
import { useApiCall } from "@/utils/api"; import { useApiCall } from "@/utils/api";
@@ -36,10 +44,15 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
const [bundleForTime, setBundleForTime] = useState<string>(""); const [bundleForTime, setBundleForTime] = useState<string>("");
const [logFileCount, setLogFileCount] = useState<string>("10"); const [logFileCount, setLogFileCount] = useState<string>("10");
const [anonymize, setAnonymize] = useState<boolean>(false); const [anonymize, setAnonymize] = useState<boolean>(false);
const [anonymizeLevel, setAnonymizeLevel] = useState<"default" | "strict">(
"default",
);
const [uploadUrl, setUploadUrl] = useState<string>("");
const isValid = useMemo(() => { const isValid = useMemo(() => {
let validBundleFor = true; let validBundleFor = true;
let validLogFileCount = true; let validLogFileCount = true;
let validUploadUrl = true;
const logFileCountNumber = Number(logFileCount); const logFileCountNumber = Number(logFileCount);
const bundleForTimeNumber = Number(bundleForTime); const bundleForTimeNumber = Number(bundleForTime);
@@ -50,8 +63,18 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000; validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
return validLogFileCount && validBundleFor; const trimmedUploadUrl = uploadUrl.trim();
}, [bundleForTime, logFileCount]); 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 () => { const createDebugJob = async () => {
notify({ notify({
@@ -64,11 +87,13 @@ export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
type: "bundle", type: "bundle",
parameters: { parameters: {
anonymize, anonymize,
anonymize_level: anonymize ? anonymizeLevel : undefined,
bundle_for: bundleForTimeEnabled, bundle_for: bundleForTimeEnabled,
bundle_for_time: bundleForTimeEnabled bundle_for_time: bundleForTimeEnabled
? Number(bundleForTime) ? Number(bundleForTime)
: undefined, : undefined,
log_file_count: logFileCount ? Number(logFileCount) : 10, 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." 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> </div>
<ModalFooter className="items-center"> <ModalFooter className="items-center">
+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;
Loaded 20 of 26 files, more files were not shown because too many files have changed in this diff. Show more