mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a3fc271da | ||
|
|
cba2613e68 | ||
|
|
fe32179dc1 | ||
|
|
1bad86b277 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 > 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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user