Compare commits

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

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ export default defineConfig({
},
testDir: "./tests",
webServer: {
command: "npx serve out -p 1337 --no-request-logging",
command: "npx serve@latest out -p 1337 --no-request-logging",
port: 1337,
reuseExistingServer: true,
cwd: path.resolve(__dirname, ".."),
+1 -2
View File
@@ -12,7 +12,7 @@
* item, which toggles purely on the focused-view flag and does not depend on
* premium permission modules.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
const SIGNUP_SOURCE_KEY = "netbird-signup-source";
@@ -149,7 +149,6 @@ const FOCUS_HIDDEN_NAV = ["Networks", "Reverse Proxy", "DNS", "Activity"];
// Agent Network view routes that make up the focused surface.
const AGENT_VIEW_PATHS = [
"/agent-network/providers",
"/agent-network/playground",
"/agent-network/policies",
"/agent-network/usage",
"/agent-network/configuration",
-426
View File
@@ -1,426 +0,0 @@
import {
type Browser,
expect,
type Page,
type Route,
test,
} from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const ALLOW = { create: true, read: true, update: true, delete: true };
const DENY = { create: false, read: false, update: false, delete: false };
const SETTINGS = {
endpoint: "violet.proxy.example.com",
proxy_address: "proxy.example.com",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const ALICE = {
id: "user-alice",
email: "alice@example.com",
name: "Alice",
role: "user",
status: "active",
auto_groups: ["group-engineering"],
permissions: { is_restricted: false, modules: {} },
};
const BOB = {
...ALICE,
id: "user-bob",
email: "bob@example.com",
name: "Bob",
auto_groups: [],
};
function currentUser(peerRead = true, groupRead = true) {
return {
...ALICE,
id: "owner-1",
email: "owner@example.com",
name: "Owner",
role: "owner",
is_current: true,
permissions: {
is_restricted: false,
modules: {
services: ALLOW,
"agent_network.providers": ALLOW,
peers: peerRead ? ALLOW : DENY,
users: peerRead ? ALLOW : DENY,
groups: groupRead ? ALLOW : DENY,
},
},
};
}
async function fulfillJSON(route: Route, body: unknown) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function mockPlaygroundDependencies(
page: Page,
opts: { peerRead?: boolean; groupRead?: boolean } = {},
) {
await page.route("**/api/users/current**", (route) =>
fulfillJSON(route, currentUser(opts.peerRead, opts.groupRead)),
);
await page.route("**/api/users?**", (route) => {
const serviceUser = new URL(route.request().url()).searchParams.get(
"service_user",
);
return fulfillJSON(route, serviceUser === "true" ? [] : [ALICE, BOB]);
});
await page.route("**/api/peers**", (route) =>
fulfillJSON(route, [
{
id: "peer-alice",
name: "Alice Laptop",
hostname: "alice-laptop",
ip: "100.64.0.10",
user_id: ALICE.id,
},
{
id: "peer-bob",
name: "Bob Laptop",
hostname: "bob-laptop",
ip: "100.64.0.11",
user_id: BOB.id,
},
]),
);
await page.route("**/api/groups**", (route) =>
fulfillJSON(route, [
{ id: "group-engineering", name: "Engineering", peers: [] },
{ id: "group-sales", name: "Sales", peers: [] },
]),
);
await page.route("**/api/agent-network/settings**", (route) =>
fulfillJSON(route, SETTINGS),
);
for (const path of ["providers", "policies", "guardrails", "budget-rules"]) {
await page.route(`**/api/agent-network/${path}**`, (route) =>
fulfillJSON(route, []),
);
}
}
async function openPlayground(
browser: Browser,
opts: { peerRead?: boolean; groupRead?: boolean } = {},
) {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) =>
window.localStorage.setItem(key as string, value as string),
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await loginToApp(page, "owner");
await mockPlaygroundDependencies(page, opts);
await page.goto("/agent-network/playground");
return { page, close: () => context.close() };
}
async function selectAlicePeer(page: Page) {
await page.getByRole("button", { name: "Select a user..." }).click();
await page.getByText("Alice", { exact: true }).click();
const peerSelect = page.getByRole("combobox", { name: "Peer" });
await peerSelect.click();
const alicePeer = page.getByRole("option", { name: /Alice Laptop/ });
await expect(alicePeer).toBeVisible();
await expect(page.getByRole("option", { name: /Bob Laptop/ })).toHaveCount(0);
await alicePeer.click();
}
test.describe("Agent Network playground @agent-network", () => {
test("runs peer and synthetic group requests with raw result states", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser);
const requests: unknown[] = [];
let call = 0;
await page.route("**/api/agent-network/playground", async (route) => {
requests.push(route.request().postDataJSON());
call += 1;
if (call === 1) {
return fulfillJSON(route, {
status_code: 200,
headers: [
{ name: "Content-Type", values: ["text/event-stream"] },
{ name: "X-Test", values: ["one", "two"] },
],
body: 'data: {"delta":"pong"}\n\n',
body_encoding: "utf8",
body_truncated: true,
identity: {
user_id: ALICE.id,
user_email: ALICE.email,
group_ids: ["group-engineering"],
group_names: ["Engineering"],
},
policy: {
decision: "allow",
reason: "",
provider_surface: "openai",
model: "gpt-4o",
resolved_provider_id: "provider-1",
authorising_group_ids: ["group-engineering"],
selected_policy_id: "policy-1",
attribution_group_id: "group-engineering",
},
});
}
return fulfillJSON(route, {
status_code: 403,
headers: [{ name: "Content-Type", values: ["application/json"] }],
body: "/wA=",
body_encoding: "base64",
body_truncated: false,
identity: {
user_id: "",
user_email: "",
group_ids: ["group-sales"],
group_names: ["Sales"],
},
policy: {
decision: "deny",
reason: "no_authorised_provider",
provider_surface: "openai",
model: "gpt-4o",
resolved_provider_id: "",
authorising_group_ids: [],
selected_policy_id: "",
attribution_group_id: "",
},
});
});
try {
await expect(
page.getByRole("heading", { level: 1, name: "Playground" }),
).toBeVisible();
await expect(
page.getByText(
"This sends a real provider request. It can incur cost and updates usage, budgets, and access logs for the emulated identity.",
),
).toBeVisible();
await selectAlicePeer(page);
await page.getByRole("button", { name: "Run live request" }).click();
const response = page.getByRole("region", {
name: "Response inspector",
});
await expect(response.getByText("HTTP 200")).toBeVisible();
await expect(response).toContainText('data: {"delta":"pong"}');
await expect(response.getByText(/Response exceeded 8 MiB/)).toBeVisible();
expect(requests[0]).toMatchObject({
principal: { kind: "peer", id: "peer-alice" },
});
await response.getByRole("tab", { name: /Headers/i }).click();
await expect(
response.getByText("Content-Type", { exact: true }),
).toBeVisible();
await expect(response.getByText("one", { exact: true })).toBeVisible();
await expect(response.getByText("two", { exact: true })).toBeVisible();
await response.getByRole("tab", { name: /Details/i }).click();
await expect(
response.getByText(ALICE.email, { exact: true }),
).toBeVisible();
await expect(
response.getByText("Engineering (group-engineering)", { exact: true }),
).toBeVisible();
await expect(
response.getByText("provider-1", { exact: true }),
).toBeVisible();
await expect(
response.getByText("policy-1", { exact: true }),
).toBeVisible();
await page.getByRole("radio", { name: /Synthetic group/ }).click();
await page.getByRole("button", { name: "Select a group..." }).click();
await page.getByText("Sales", { exact: true }).click();
await expect(
page.getByText(
"Synthetic group identity: per-user limits do not apply.",
),
).toBeVisible();
await page.getByRole("button", { name: "Run live request" }).click();
await expect(response.getByText("HTTP 403")).toBeVisible();
await response.getByRole("tab", { name: /Body/i }).click();
const bodyPanel = response.getByRole("tabpanel", { name: /Body/i });
await expect(bodyPanel.getByText("base64")).toBeVisible();
await expect(bodyPanel.getByText("/wA=")).toBeVisible();
expect(requests[1]).toMatchObject({
principal: { kind: "group", id: "group-sales" },
});
} finally {
await close();
}
});
test("blocks protected headers and cancels an in-flight request", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser);
await page.route("**/api/agent-network/playground", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2_000));
return fulfillJSON(route, {});
});
try {
await selectAlicePeer(page);
const headerName = page.getByLabel("Header 1 name");
await headerName.fill("Authorization");
await expect(page.getByText(/managed by NetBird/)).toBeVisible();
await expect(
page.getByRole("button", { name: "Run live request" }),
).toBeDisabled();
await headerName.fill("Content-Type");
await page.getByRole("button", { name: "Run live request" }).click();
await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("button", { name: "Cancel" })).toHaveCount(0);
await expect(
page.getByRole("region", { name: "Response inspector" }),
).not.toContainText("Request failed");
} finally {
await close();
}
});
test("shows management failures with their request ID", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser);
await page.route("**/api/agent-network/playground", (route) =>
route.fulfill({
status: 503,
headers: {
"Content-Type": "application/json",
"X-Request-Id": "request-503",
"Access-Control-Expose-Headers": "X-Request-Id",
},
body: JSON.stringify({
code: 503,
message: "No playground-capable proxy is connected.",
}),
}),
);
try {
await selectAlicePeer(page);
await page.getByRole("button", { name: "Run live request" }).click();
const response = page.getByRole("region", {
name: "Response inspector",
});
await expect(response.getByRole("alert")).toContainText(
"No playground-capable proxy is connected.",
);
await expect(response.getByRole("alert")).toContainText(
"Request ID: request-503",
);
await expect(response).not.toContainText("HTTP 503");
} finally {
await close();
}
});
test("keeps the request and response workspace responsive", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser);
try {
const request = page.getByRole("region", { name: "Request builder" });
const response = page.getByRole("region", {
name: "Response inspector",
});
await page.setViewportSize({ width: 1440, height: 1000 });
await expect(request).toBeVisible();
await expect(response).toBeVisible();
const desktopRequest = await request.boundingBox();
const desktopResponse = await response.boundingBox();
expect(desktopRequest).not.toBeNull();
expect(desktopResponse).not.toBeNull();
expect(Math.abs(desktopRequest!.y - desktopResponse!.y)).toBeLessThan(4);
expect(desktopResponse!.x).toBeGreaterThanOrEqual(
desktopRequest!.x + desktopRequest!.width,
);
await page.setViewportSize({ width: 1024, height: 900 });
const narrowRequest = await request.boundingBox();
const narrowResponse = await response.boundingBox();
expect(narrowRequest).not.toBeNull();
expect(narrowResponse).not.toBeNull();
expect(narrowResponse!.y).toBeGreaterThanOrEqual(
narrowRequest!.y + narrowRequest!.height,
);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth ===
document.documentElement.clientWidth,
),
).toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
await expect(
page.getByRole("heading", { level: 1, name: "Playground" }),
).toBeVisible();
await expect(
request.getByRole("button", { name: "Run live request" }),
).toBeVisible();
await expect(request).toBeVisible();
await expect(response).toBeVisible();
const mobileRequest = await request.boundingBox();
const mobileResponse = await response.boundingBox();
expect(mobileRequest).not.toBeNull();
expect(mobileResponse).not.toBeNull();
expect(mobileResponse!.y).toBeGreaterThanOrEqual(
mobileRequest!.y + mobileRequest!.height,
);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth ===
document.documentElement.clientWidth,
),
).toBe(true);
} finally {
await close();
}
});
test("disables only the principal mode without read permission", async ({
browser,
}) => {
const { page, close } = await openPlayground(browser, {
peerRead: false,
groupRead: true,
});
try {
await expect(
page.getByRole("radio", { name: /Peer-backed user/ }),
).toBeDisabled();
await expect(
page.getByRole("radio", { name: /Synthetic group/ }),
).toBeEnabled();
} finally {
await close();
}
});
});
+8 -827
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -112,7 +112,6 @@
"jsdom": "^29.1.1",
"postcss": "^8",
"prettier": "3.0.3",
"serve": "^14.2.6",
"tailwindcss": "^3.4.17",
"vitest": "^4.1.10"
}
@@ -1,64 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PageContainer from "@/layouts/PageContainer";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
import AgentNetworkPlayground from "@/modules/agent-network/playground/AgentNetworkPlayground";
function PlaygroundPageContent() {
const { permission } = usePermissions();
const canReadPrincipal = Boolean(
(permission?.peers?.read && permission?.users?.read) ||
permission?.groups?.read,
);
return (
<RestrictedAccess page="Playground identities" hasAccess={canReadPrincipal}>
<PeersProvider>
<AIProvidersProvider>
<div className="p-default py-6">
<Breadcrumbs>
<Breadcrumbs.Item
href="/agent-network/providers"
label="Agent Network"
icon={<AgentNetworkIcon size={16} />}
/>
<Breadcrumbs.Item
href="/agent-network/playground"
label="Playground"
active
/>
</Breadcrumbs>
<h1>Playground</h1>
<Paragraph>
Emulate a peer-backed user or a synthetic group and inspect the
real policy, provider, accounting, and response path.
</Paragraph>
<div className="mt-6">
<AgentNetworkPlayground />
</div>
</div>
</AIProvidersProvider>
</PeersProvider>
</RestrictedAccess>
);
}
export default function AgentNetworkPlaygroundPage() {
const { permission } = usePermissions();
return (
<PageContainer>
<RestrictedAccess
page="Agent Network Playground"
hasAccess={permission?.["agent_network.providers"]?.create}
>
<PlaygroundPageContent />
</RestrictedAccess>
</PageContainer>
);
}
+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

+4 -14
View File
@@ -1,19 +1,15 @@
"use client";
import { ScrollArea } from "@components/ScrollArea";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers";
import { isNetBirdCloud } from "@utils/netbird";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import { DistributorNavigation } from "@/cloud/distributor/DistributorNavigation";
@@ -28,6 +24,10 @@ import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode
import { useMyAgentNetworkSetup } from "@/modules/agent-network/useMyAgentNetworkSetup";
import { NavigationUsageInfo } from "@/modules/billing/NavigationUsageInfo";
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
import { SmallBadge } from "@components/ui/SmallBadge";
import * as React from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
type Props = {
fullWidth?: boolean;
@@ -260,16 +260,6 @@ export default function Navigation({
permission?.["agent_network.providers"]?.read
}
/>
<SidebarItem
label="Playground"
isChild
href={"/agent-network/playground"}
exactPathMatch={true}
visible={
agentNetworkSurface &&
permission?.["agent_network.providers"]?.create
}
/>
<SidebarItem
label="Policies"
isChild
@@ -1,147 +0,0 @@
"use client";
import { Callout } from "@components/Callout";
import { useEffect, useMemo, useRef, useState } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import { useAIProviders } from "@/modules/agent-network/AIProvidersProvider";
import { PlaygroundRequest, PlaygroundResponse, usePlaygroundApi } from "./api";
import PlaygroundPrincipalSelector, {
PlaygroundPrincipalSelection,
} from "./PlaygroundPrincipalSelector";
import PlaygroundRequestEditor, {
DEFAULT_PLAYGROUND_REQUEST,
RawPlaygroundRequest,
validatePlaygroundRequest,
} from "./PlaygroundRequestEditor";
import PlaygroundResult from "./PlaygroundResult";
export default function AgentNetworkPlayground() {
const { permission } = usePermissions();
const { users = [], isLoading: usersLoading } = useUsers();
const { peers = [], isLoading: peersLoading } = usePeers();
const { groups = [], isLoading: groupsLoading } = useGroups();
const { settings, settingsLoading } = useAIProviders();
const api = usePlaygroundApi();
const canUsePeer = Boolean(
permission?.peers?.read && permission?.users?.read,
);
const canUseGroup = Boolean(permission?.groups?.read);
const canReadSettings = Boolean(permission?.["agent_network.settings"]?.read);
const [principal, setPrincipal] = useState<PlaygroundPrincipalSelection>({
mode: canUsePeer ? "peer" : "group",
});
const [request, setRequest] = useState<RawPlaygroundRequest>(
DEFAULT_PLAYGROUND_REQUEST,
);
const [result, setResult] = useState<PlaygroundResponse>();
const [error, setError] = useState<string>();
const [running, setRunning] = useState(false);
const controllerRef = useRef<AbortController | undefined>(undefined);
useEffect(() => {
if (principal.mode === "peer" && !canUsePeer && canUseGroup) {
setPrincipal({ mode: "group" });
} else if (principal.mode === "group" && !canUseGroup && canUsePeer) {
setPrincipal({ mode: "peer" });
}
}, [canUseGroup, canUsePeer, principal.mode]);
const validationError = useMemo(
() => validatePlaygroundRequest(request),
[request],
);
const principalID =
principal.mode === "peer" ? principal.peer?.id : principal.group?.id;
const loading = usersLoading || peersLoading || groupsLoading;
const canRun =
!running && !loading && Boolean(principalID) && !validationError;
const run = async () => {
if (!canRun || !principalID) return;
const controller = new AbortController();
controllerRef.current = controller;
setRunning(true);
setError(undefined);
const payload: PlaygroundRequest = {
principal: { kind: principal.mode, id: principalID },
method: request.method,
path: request.path,
headers: request.headers.map((header) => ({
name: header.name,
values: [header.value],
})),
body: request.body,
};
try {
const response = await api.post(payload, "", {
signal: controller.signal,
});
setResult(response);
} catch (cause) {
if (!controller.signal.aborted) {
const apiError = cause as { message?: string; requestId?: string };
setError(
`${apiError.message || "Playground request failed."}${
apiError.requestId ? ` Request ID: ${apiError.requestId}` : ""
}`,
);
}
} finally {
if (controllerRef.current === controller) {
controllerRef.current = undefined;
}
setRunning(false);
}
};
return (
<div className="flex min-w-0 flex-col gap-4">
{canReadSettings && !settings && !settingsLoading && (
<div role="alert">
<Callout variant="warning">
Configure the Agent Network endpoint and connect a
playground-capable proxy before running a request.
</Callout>
</div>
)}
<Callout variant="warning">
This sends a real provider request. It can incur cost and updates usage,
budgets, and access logs for the emulated identity.
</Callout>
<div className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-2">
<div
role="region"
aria-label="Request builder"
className="flex min-w-0 flex-col gap-6"
>
<PlaygroundPrincipalSelector
value={principal}
onChange={setPrincipal}
users={users}
peers={peers}
groups={groups}
canUsePeer={canUsePeer}
canUseGroup={canUseGroup}
disabled={running}
/>
<PlaygroundRequestEditor
value={request}
onChange={setRequest}
disabled={running}
error={validationError}
running={running}
canRun={canRun}
onRun={run}
onCancel={() => controllerRef.current?.abort()}
/>
</div>
<PlaygroundResult result={result} running={running} error={error} />
</div>
</div>
);
}
@@ -1,423 +0,0 @@
"use client";
import Button from "@components/Button";
import ButtonGroup from "@components/ButtonGroup";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import { DropdownInfoText } from "@components/DropdownInfoText";
import { DropdownInput } from "@components/DropdownInput";
import { Label } from "@components/Label";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { UserListItem } from "@components/UserSelector";
import { Check, ChevronDown } from "lucide-react";
import { type ChangeEvent, useState } from "react";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import { User } from "@/interfaces/User";
import { PlaygroundPrincipalKind } from "./api";
export type PlaygroundPrincipalSelection = {
mode: PlaygroundPrincipalKind;
user?: User;
peer?: Peer;
group?: Group;
};
type Props = {
value: PlaygroundPrincipalSelection;
onChange: (value: PlaygroundPrincipalSelection) => void;
users: User[];
peers: Peer[];
groups: Group[];
canUsePeer: boolean;
canUseGroup: boolean;
disabled: boolean;
};
function UserPicker({
value,
users,
disabled,
onSelect,
}: Readonly<{
value?: User;
users: User[];
disabled: boolean;
onSelect: (user: User) => void;
}>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const filteredUsers = users.filter((user) =>
`${user.name} ${user.email ?? ""}`.toLowerCase().includes(normalizedQuery),
);
const triggerLabel =
value?.name || (users.length ? "Select a user..." : "No users available.");
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button
id="playground-user"
variant="input"
aria-label={triggerLabel}
disabled={disabled}
className="w-full min-w-0 justify-between text-left"
>
{value ? (
<UserListItem user={value} variant="selected" />
) : (
<span className="min-w-0 truncate">{triggerLabel}</span>
)}
<ChevronDown
aria-hidden
size={15}
className="shrink-0 text-nb-gray-500"
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<DropdownInput
aria-label="Search users"
value={query}
onChange={(nextQuery: string | ChangeEvent<HTMLInputElement>) =>
setQuery(
typeof nextQuery === "string"
? nextQuery
: nextQuery.target.value,
)
}
placeholder="Search users..."
hideEnterIcon
/>
<div
role="listbox"
aria-label="Users"
className="max-h-64 overflow-y-auto p-1"
>
{filteredUsers.length ? (
filteredUsers.map((user) => {
const selected = user.id === value?.id;
return (
<button
key={user.id}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onSelect(user);
setOpen(false);
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left outline-none hover:bg-nb-gray-910 focus-visible:bg-nb-gray-910"
>
<UserListItem user={user} />
{selected && (
<Check
aria-hidden
size={15}
className="shrink-0 text-netbird"
/>
)}
</button>
);
})
) : (
<DropdownInfoText className="mb-4 px-3">
No users available.
</DropdownInfoText>
)}
</div>
</PopoverContent>
</Popover>
);
}
function GroupPicker({
value,
groups,
disabled,
onSelect,
}: Readonly<{
value?: Group;
groups: Group[];
disabled: boolean;
onSelect: (group: Group) => void;
}>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase();
const selectableGroups = groups.filter((group) => Boolean(group.id));
const filteredGroups = selectableGroups.filter((group) =>
group.name.toLowerCase().includes(normalizedQuery),
);
const triggerLabel = value?.name
? value.name
: selectableGroups.length
? "Select a group..."
: "No groups available.";
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button
id="playground-group"
variant="input"
aria-label={triggerLabel}
disabled={disabled}
className="w-full min-w-0 justify-between text-left"
>
<span className="flex min-w-0 items-center gap-2 truncate">
{value && (
<GroupBadgeIcon id={value.id} issued={value.issued} size={14} />
)}
<span className="truncate">{triggerLabel}</span>
</span>
<ChevronDown
aria-hidden
size={15}
className="shrink-0 text-nb-gray-500"
/>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
>
<DropdownInput
aria-label="Search groups"
value={query}
onChange={(nextQuery: string | ChangeEvent<HTMLInputElement>) =>
setQuery(
typeof nextQuery === "string"
? nextQuery
: nextQuery.target.value,
)
}
placeholder="Search groups..."
hideEnterIcon
/>
<div
role="listbox"
aria-label="Groups"
className="max-h-64 overflow-y-auto p-1"
>
{filteredGroups.length ? (
filteredGroups.map((group) => {
const selected = group.id === value?.id;
const peerCount = group.peers_count ?? group.peers?.length ?? 0;
return (
<button
key={group.id}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onSelect(group);
setOpen(false);
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-left outline-none hover:bg-nb-gray-910 focus-visible:bg-nb-gray-910"
>
<GroupBadgeIcon
id={group.id}
issued={group.issued}
size={14}
/>
<span className="min-w-0 flex-1 truncate text-sm text-nb-gray-100">
{group.name}
</span>
<span className="shrink-0 text-xs text-nb-gray-500">
{peerCount} {peerCount === 1 ? "peer" : "peers"}
</span>
{selected && (
<Check
aria-hidden
size={15}
className="shrink-0 text-netbird"
/>
)}
</button>
);
})
) : (
<DropdownInfoText className="mb-4 px-3">
No groups available.
</DropdownInfoText>
)}
</div>
</PopoverContent>
</Popover>
);
}
export default function PlaygroundPrincipalSelector({
value,
onChange,
users,
peers,
groups,
canUsePeer,
canUseGroup,
disabled,
}: Readonly<Props>) {
const userPeers = value.user
? peers.filter((peer) => peer.user_id === value.user?.id)
: [];
return (
<section className="min-w-0" aria-labelledby="playground-identity-heading">
<Card className="w-full">
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<h2
id="playground-identity-heading"
className="shrink-0 text-sm font-semibold text-nb-gray-100"
>
Identity
</h2>
<div role="radiogroup" aria-label="Identity mode">
<ButtonGroup>
<ButtonGroup.Button
role="radio"
aria-checked={value.mode === "peer"}
aria-describedby="peer-mode-description"
variant={value.mode === "peer" ? "tertiary" : "secondary"}
disabled={disabled || !canUsePeer}
onClick={() =>
onChange({
mode: "peer",
user: value.user,
peer: value.peer,
})
}
>
Peer-backed user
</ButtonGroup.Button>
<ButtonGroup.Button
role="radio"
aria-checked={value.mode === "group"}
aria-describedby="group-mode-description"
variant={value.mode === "group" ? "tertiary" : "secondary"}
disabled={disabled || !canUseGroup}
onClick={() =>
onChange({ mode: "group", group: value.group })
}
>
Synthetic group
</ButtonGroup.Button>
</ButtonGroup>
</div>
<span id="peer-mode-description" className="sr-only">
Applies per-user and group policy limits.
</span>
<span id="group-mode-description" className="sr-only">
Exercises one group without a user identity.
</span>
</div>
<div className="border-t border-nb-gray-900 pt-4">
{value.mode === "peer" ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="min-w-0">
<Label htmlFor="playground-user">User</Label>
<UserPicker
value={value.user}
users={users}
disabled={disabled || !canUsePeer || users.length === 0}
onSelect={(user) =>
onChange({ mode: "peer", user, peer: undefined })
}
/>
</div>
<div className="min-w-0">
<Label htmlFor="playground-peer">Peer</Label>
<Select
value={value.peer?.id ?? ""}
disabled={disabled || !value.user || userPeers.length === 0}
onValueChange={(peerID) =>
onChange({
mode: "peer",
user: value.user,
peer: userPeers.find((peer) => peer.id === peerID),
})
}
>
<SelectTrigger id="playground-peer" aria-label="Peer">
<SelectValue
placeholder={
value.user && userPeers.length === 0
? "This user has no peers"
: "Select a peer..."
}
/>
</SelectTrigger>
<SelectContent>
{userPeers.map((peer) => (
<SelectItem
key={peer.id}
value={peer.id!}
description={peer.ip}
>
{peer.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{value.user && userPeers.length === 0 && (
<div className="sm:col-span-2">
<Callout variant="warning">
This user has no peers to emulate.
</Callout>
</div>
)}
</div>
) : (
<div className="min-w-0">
<Label htmlFor="playground-group">Group</Label>
<GroupPicker
value={value.group}
groups={groups}
disabled={
disabled ||
!canUseGroup ||
!groups.some((group) => Boolean(group.id))
}
onSelect={(group) => onChange({ mode: "group", group })}
/>
</div>
)}
</div>
{value.mode === "group" && (
<Callout variant="info">
Synthetic group identity: per-user limits do not apply.
</Callout>
)}
</div>
</Card>
</section>
);
}
@@ -1,310 +0,0 @@
"use client";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { Textarea } from "@components/Textarea";
import { Loader2, Play, Plus, Square, Trash2 } from "lucide-react";
const MAX_REQUEST_BODY_BYTES = 1 << 20;
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const PROTECTED_HEADERS: Record<string, true> = {
authorization: true,
connection: true,
"content-length": true,
cookie: true,
forwarded: true,
host: true,
"keep-alive": true,
"proxy-authorization": true,
"proxy-connection": true,
te: true,
trailer: true,
"transfer-encoding": true,
upgrade: true,
};
export type PlaygroundHeaderRow = {
id: string;
name: string;
value: string;
};
export type RawPlaygroundRequest = {
method: "GET" | "POST";
path: string;
headers: PlaygroundHeaderRow[];
body: string;
};
export const DEFAULT_PLAYGROUND_REQUEST: RawPlaygroundRequest = {
method: "POST",
path: "/v1/chat/completions",
headers: [
{ id: "content-type", name: "Content-Type", value: "application/json" },
],
body: JSON.stringify(
{
model: "",
messages: [{ role: "user", content: "" }],
stream: false,
},
null,
2,
),
};
export function validatePlaygroundRequest(request: RawPlaygroundRequest) {
const bodyBytes = new TextEncoder().encode(request.body).byteLength;
if (bodyBytes > MAX_REQUEST_BODY_BYTES) {
return "Request body must not exceed 1 MiB.";
}
if (!request.path.startsWith("/") || request.path.startsWith("//")) {
return "Path must be origin-form and start with /.";
}
try {
const parsed = new URL(request.path, "https://playground.invalid");
if (parsed.origin !== "https://playground.invalid") {
return "Path must not contain a scheme or host.";
}
} catch {
return "Path is not a valid origin-form URL.";
}
for (const header of request.headers) {
if (!HEADER_NAME.test(header.name)) {
return `Header name ${header.name || "(empty)"} is invalid.`;
}
const lowerName = header.name.toLowerCase();
if (PROTECTED_HEADERS[lowerName] || lowerName.startsWith("x-forwarded-")) {
return `Header ${header.name} is managed by NetBird and cannot be set.`;
}
if (header.value.includes("\r") || header.value.includes("\n")) {
return `Header ${header.name} contains an invalid line break.`;
}
}
return undefined;
}
type Props = {
value: RawPlaygroundRequest;
onChange: (value: RawPlaygroundRequest) => void;
disabled: boolean;
error?: string;
running: boolean;
canRun: boolean;
onRun: () => void;
onCancel: () => void;
};
export default function PlaygroundRequestEditor({
value,
onChange,
disabled,
error,
running,
canRun,
onRun,
onCancel,
}: Readonly<Props>) {
const updateHeader = (
index: number,
field: "name" | "value",
next: string,
) => {
onChange({
...value,
headers: value.headers.map((header, headerIndex) =>
headerIndex === index ? { ...header, [field]: next } : header,
),
});
};
return (
<section className="min-w-0" aria-labelledby="playground-request-heading">
<Card className="w-full">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-nb-gray-900 px-4 py-3">
<h2
id="playground-request-heading"
className="text-sm font-semibold text-nb-gray-100"
>
Raw request
</h2>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="primary"
size="xs"
disabled={!canRun}
onClick={onRun}
>
{running ? (
<Loader2 aria-hidden size={15} className="animate-spin" />
) : (
<Play aria-hidden size={15} />
)}
Run live request
</Button>
{running && (
<Button variant="danger-outline" size="xs" onClick={onCancel}>
<Square aria-hidden size={14} /> Cancel
</Button>
)}
</div>
</div>
<div className="space-y-5 p-4">
<div className="grid gap-3 sm:grid-cols-[7.5rem_minmax(0,1fr)]">
<div>
<Label htmlFor="playground-method">Method</Label>
<Select
value={value.method}
disabled={disabled}
onValueChange={(method) =>
onChange({
...value,
method: method as "GET" | "POST",
})
}
>
<SelectTrigger
id="playground-method"
aria-label="Method"
className="font-mono"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
</div>
<div className="min-w-0">
<Label htmlFor="playground-path">Path</Label>
<Input
id="playground-path"
aria-label="Path"
className="font-mono"
value={value.path}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, path: event.target.value })
}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label as="div">Headers</Label>
<Button
size="xs"
variant="secondary"
disabled={disabled}
onClick={() =>
onChange({
...value,
headers: [
...value.headers,
{ id: crypto.randomUUID(), name: "", value: "" },
],
})
}
>
<Plus aria-hidden size={14} /> Add header
</Button>
</div>
{value.headers.map((header, index) => {
const nameID = `playground-header-name-${header.id}`;
const valueID = `playground-header-value-${header.id}`;
return (
<div
key={header.id}
className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)_32px]"
>
<label htmlFor={nameID} className="sr-only">
Header {index + 1} name
</label>
<Input
id={nameID}
aria-label={`Header ${index + 1} name`}
className="min-w-0 font-mono"
value={header.name}
placeholder="Header name"
disabled={disabled}
onChange={(event) =>
updateHeader(index, "name", event.target.value)
}
/>
<label htmlFor={valueID} className="sr-only">
Header {index + 1} value
</label>
<Input
id={valueID}
aria-label={`Header ${index + 1} value`}
className="min-w-0 font-mono"
value={header.value}
placeholder="Header value"
disabled={disabled}
onChange={(event) =>
updateHeader(index, "value", event.target.value)
}
/>
<Button
aria-label={`Remove header ${index + 1}`}
variant="danger-outline"
disabled={disabled}
className="h-8 w-8 p-0"
onClick={() =>
onChange({
...value,
headers: value.headers.filter(
(_, headerIndex) => headerIndex !== index,
),
})
}
>
<Trash2 aria-hidden size={14} />
</Button>
</div>
);
})}
</div>
<div>
<Label htmlFor="playground-body">Body</Label>
<Textarea
id="playground-body"
aria-label="Body"
className="min-h-[260px] overflow-auto font-mono text-xs leading-5"
value={value.body}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, body: event.target.value })
}
resize
/>
<div className="mt-1.5 text-right font-mono text-[10px] text-nb-gray-500">
{new TextEncoder().encode(value.body).byteLength.toLocaleString()}{" "}
/ {MAX_REQUEST_BODY_BYTES.toLocaleString()} bytes
</div>
</div>
{error && (
<div role="alert">
<Callout variant="error">{error}</Callout>
</div>
)}
</div>
</Card>
</section>
);
}
@@ -1,283 +0,0 @@
"use client";
import Badge from "@components/Badge";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { Loader2 } from "lucide-react";
import { PlaygroundResponse } from "./api";
type Props = {
result?: PlaygroundResponse;
running: boolean;
error?: string;
};
function Value({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<dd className="min-w-0 break-all font-mono text-xs text-nb-gray-250">
{children || "—"}
</dd>
);
}
function ResponseBody({
result,
running,
}: Readonly<{ result?: PlaygroundResponse; running: boolean }>) {
const body = result?.body || "(empty)";
const lines = body.split("\n");
return (
<TabsContent value="body" className="mt-0 min-h-0 flex-1 pt-0 outline-none">
<div className="flex h-full min-h-0 flex-col gap-3 p-4">
{result?.body_truncated && (
<div role="alert">
<Callout variant="warning">
Response exceeded 8 MiB. Only the returned prefix is shown.
</Callout>
</div>
)}
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-medium uppercase tracking-wider text-nb-gray-500">
Response body
</span>
<span className="font-mono text-[10px] uppercase text-nb-gray-500">
{result?.body_encoding || "—"}
</span>
</div>
<div className="min-h-64 min-w-0 flex-1 overflow-auto rounded-md border border-nb-gray-900 bg-nb-gray-950 py-3">
{running && !result ? (
<div className="flex h-full min-h-56 items-center justify-center gap-2 text-sm text-nb-gray-500">
<Loader2 aria-hidden size={16} className="animate-spin" />
Waiting for response…
</div>
) : (
<div className="min-w-max font-mono text-xs leading-5 text-nb-gray-250">
{lines.map((line, index) => (
<div
key={`${index}-${line}`}
className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)]"
>
<span
aria-hidden
className="select-none border-r border-nb-gray-900 pr-3 text-right text-nb-gray-700"
>
{index + 1}
</span>
<span className="min-w-0 whitespace-pre-wrap break-all px-3">
{line || <span aria-hidden>&nbsp;</span>}
</span>
</div>
))}
</div>
)}
</div>
</div>
</TabsContent>
);
}
function ResponseHeaders({
result,
}: Readonly<{ result?: PlaygroundResponse }>) {
const headers =
result?.headers.flatMap((header) =>
header.values.map((value) => ({ name: header.name, value })),
) ?? [];
return (
<TabsContent
value="headers"
className="mt-0 min-h-0 flex-1 pt-0 outline-none"
>
<div className="h-full min-h-64 overflow-auto p-4">
{headers.length ? (
<div className="overflow-hidden rounded-md border border-nb-gray-900">
{headers.map((header, index) => (
<div
key={`${header.name}-${index}`}
className="grid min-w-0 gap-1 border-b border-nb-gray-900 bg-nb-gray-950 px-3 py-2.5 last:border-b-0 sm:grid-cols-[minmax(8rem,0.65fr)_minmax(0,1fr)] sm:gap-4"
>
<span
className={`break-all font-mono text-xs ${
header.name.toLowerCase().startsWith("x-netbird-")
? "text-netbird"
: "text-nb-gray-400"
}`}
>
{header.name}
</span>
<span className="min-w-0 break-all font-mono text-xs text-nb-gray-250">
{header.value}
</span>
</div>
))}
</div>
) : (
<div className="rounded-md border border-nb-gray-900 bg-nb-gray-950 px-3 py-4 font-mono text-xs text-nb-gray-500">
(none)
</div>
)}
</div>
</TabsContent>
);
}
function ResponseDetails({
result,
}: Readonly<{ result?: PlaygroundResponse }>) {
const groups =
result?.identity.group_names
.map(
(name, index) => `${name} (${result.identity.group_ids[index] ?? "?"})`,
)
.join(", ") || result?.identity.group_ids.join(", ");
return (
<TabsContent
value="details"
className="mt-0 min-h-0 flex-1 pt-0 outline-none"
>
<div className="grid gap-6 overflow-auto p-4 lg:grid-cols-2">
<div className="min-w-0">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-nb-gray-400">
Authoritative identity
</h3>
<dl className="grid grid-cols-[7rem_minmax(0,1fr)] gap-x-3 gap-y-2.5">
<dt className="text-xs text-nb-gray-500">User ID</dt>
<Value>{result?.identity.user_id}</Value>
<dt className="text-xs text-nb-gray-500">User email</dt>
<Value>{result?.identity.user_email}</Value>
<dt className="text-xs text-nb-gray-500">Groups</dt>
<Value>{groups}</Value>
</dl>
</div>
<div className="min-w-0">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-nb-gray-400">
Policy attribution
</h3>
<dl className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-x-3 gap-y-2.5">
<dt className="text-xs text-nb-gray-500">Reason</dt>
<Value>{result?.policy.reason}</Value>
<dt className="text-xs text-nb-gray-500">Provider surface</dt>
<Value>{result?.policy.provider_surface}</Value>
<dt className="text-xs text-nb-gray-500">Model</dt>
<Value>{result?.policy.model}</Value>
<dt className="text-xs text-nb-gray-500">Resolved provider</dt>
<Value>{result?.policy.resolved_provider_id}</Value>
<dt className="text-xs text-nb-gray-500">Selected policy</dt>
<Value>{result?.policy.selected_policy_id}</Value>
<dt className="text-xs text-nb-gray-500">Attribution group</dt>
<Value>{result?.policy.attribution_group_id}</Value>
<dt className="text-xs text-nb-gray-500">Authorising groups</dt>
<Value>{result?.policy.authorising_group_ids.join(", ")}</Value>
</dl>
</div>
</div>
</TabsContent>
);
}
export default function PlaygroundResult({
result,
running,
error,
}: Readonly<Props>) {
const visibleResult = error ? undefined : result;
const allowed = visibleResult?.policy.decision === "allow";
const providerLabel = visibleResult
? [
visibleResult.policy.resolved_provider_id,
visibleResult.policy.provider_surface,
visibleResult.policy.model,
]
.filter(Boolean)
.join(" · ")
: "";
const status = error
? "Request failed"
: running
? "Sending request…"
: visibleResult
? `HTTP ${visibleResult.status_code}`
: "Not run";
return (
<section
aria-label="Response inspector"
aria-live="polite"
aria-busy={running}
className="h-full min-h-80 min-w-0"
>
<Card className="flex h-full min-h-80 w-full flex-col">
<div className="flex min-h-16 flex-wrap items-center justify-between gap-3 border-b border-nb-gray-900 px-4 py-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-nb-gray-100">Response</h2>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-2">
{running && (
<Loader2
aria-hidden
size={14}
className="shrink-0 animate-spin text-netbird"
/>
)}
<span
className={`font-mono text-xs font-medium ${
error ? "text-red-400" : "text-nb-gray-100"
}`}
>
{status}
</span>
{visibleResult && !error && (
<Badge size="xs" variant={allowed ? "green" : "red"}>
{visibleResult.policy.decision || "unknown"}
</Badge>
)}
</div>
</div>
{visibleResult && !error && (
<div className="flex min-w-0 flex-wrap items-center justify-end gap-x-3 gap-y-1 font-mono text-[10px] uppercase text-nb-gray-500">
<span>{visibleResult.body_encoding}</span>
{providerLabel && (
<span className="max-w-full break-all normal-case">
{providerLabel}
</span>
)}
</div>
)}
</div>
{error ? (
<div className="flex min-h-64 flex-1 items-start p-4">
<div role="alert" className="w-full">
<Callout variant="error">{error}</Callout>
</div>
</div>
) : (
<Tabs
defaultValue="body"
className={`flex min-h-0 flex-1 flex-col transition-opacity ${
running && visibleResult ? "opacity-50" : ""
}`}
>
<TabsList
aria-label="Response views"
justify="start"
className="shrink-0 px-4 pt-3"
>
<TabsTrigger value="body">Body</TabsTrigger>
<TabsTrigger value="headers">Headers</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<ResponseBody result={visibleResult} running={running} />
<ResponseHeaders result={visibleResult} />
<ResponseDetails result={visibleResult} />
</Tabs>
)}
</Card>
</section>
);
}
@@ -1,47 +0,0 @@
import { useApiCall } from "@utils/api";
export type PlaygroundPrincipalKind = "peer" | "group";
export type PlaygroundHeader = {
name: string;
values: string[];
};
export type PlaygroundRequest = {
principal: {
kind: PlaygroundPrincipalKind;
id: string;
};
method: "GET" | "POST";
path: string;
headers: PlaygroundHeader[];
body: string;
};
export type PlaygroundResponse = {
status_code: number;
headers: PlaygroundHeader[];
body: string;
body_encoding: "utf8" | "base64";
body_truncated: boolean;
identity: {
user_id: string;
user_email: string;
group_ids: string[];
group_names: string[];
};
policy: {
decision: string;
reason: string;
provider_surface: string;
model: string;
resolved_provider_id: string;
authorising_group_ids: string[];
selected_policy_id: string;
attribution_group_id: string;
};
};
export function usePlaygroundApi() {
return useApiCall<PlaygroundResponse>("/agent-network/playground", true);
}
+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;
Loaded 20 of 25 files, more files were not shown because too many files have changed in this diff. Show more