Compare commits

..
5 Commits
Author SHA1 Message Date
Eduard GertandClaude Opus 4.8 d5f61efc1a Fix network resource policy creation with single peer source (#721)
When creating a network resource and adding a policy with a single peer
(sourceResource) as the source, the payload included both an empty
`sources: []` array and `sourceResource`, causing the backend to reject
it with "specify either sources or source resources not both".

`createPolicyForResource` always computed `sources` as an array while the
`...rule` spread carried `sourceResource` through. This mirrors the
existing destinations handling: gate `sources` on `rule.sourceResource`
so it becomes undefined (and is dropped during serialization) when a
source resource is set.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:16:36 +02:00
Maycon Santos 3503141c56 Adjust license cache ttl to 10 minutes (#718) 2026-07-20 11:03:14 +02:00
Misha Bragin a27e4654cf Fix IdP cards spacing (#717) 2026-07-16 14:41:59 +02:00
Misha Bragin b9569f48d5 Add clusters view to agent network (#714) 2026-07-15 23:22:05 +02:00
Viktor Liu 9694bb753d Show RDP for Linux and FreeBSD peers and harden the browser RDP client (#710) 2026-07-15 12:29:54 +02:00
10 changed files with 257 additions and 78 deletions

No files matched your search

+3 -3
View File
@@ -39,7 +39,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
@@ -48,7 +48,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
@@ -57,7 +57,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
tag: v0.0.2
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
@@ -1,25 +1,33 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import * as Tabs from "@radix-ui/react-tabs";
import { Gauge, ScrollText } from "lucide-react";
import { ExternalLinkIcon, Gauge, ScrollText, ServerIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { Suspense, useEffect, useState } from "react";
import React, { lazy, Suspense, useEffect, useState } from "react";
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
const ClustersTable = lazy(
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
);
const TAB_BUDGET_SETTINGS = "budget-settings";
const TAB_LOG_SETTINGS = "log-settings";
const TAB_CLUSTERS = "clusters";
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
// Usage & Logs — global limits and log-collection controls — using the same
@@ -27,6 +35,7 @@ const TAB_LOG_SETTINGS = "log-settings";
// Settings page so it reads as "settings for the Agent Network".
export default function AgentNetworkConfigurationPage() {
const { permission } = usePermissions();
const { only: agentNetworkOnly } = useAgentNetworkMode();
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
@@ -47,6 +56,10 @@ export default function AgentNetworkConfigurationPage() {
<ScrollText size={14} />
Log Collection
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
<ServerIcon size={14} />
Clusters
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess
page={"Configuration"}
@@ -82,6 +95,27 @@ export default function AgentNetworkConfigurationPage() {
<AgentAccountControlsCard />
</Suspense>
</Tabs.Content>
<Tabs.Content value={TAB_CLUSTERS} className={"w-full"}>
<ConfigTabHeader
label={"Clusters"}
href={"/agent-network/configuration?tab=clusters"}
>
{agentNetworkOnly
? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment."
: "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</ConfigTabHeader>
<Suspense fallback={<SkeletonTable />}>
<ClustersTable />
</Suspense>
</Tabs.Content>
</div>
</AIProvidersProvider>
</PeersProvider>
+16 -1
View File
@@ -7,6 +7,8 @@ import useFetchApi from "@utils/api";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Peer } from "@/interfaces/Peer";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
@@ -138,6 +140,8 @@ function RDPSession({ peer }: Props) {
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
dynamicResize:
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS,
});
if (result === RDPStatus.CONNECTED) {
connected.current = true;
@@ -182,7 +186,18 @@ function RDPSession({ peer }: Props) {
if (client.error) {
sendErrorNotification("NetBird Client Error", client.error);
}
}, [rdp, client]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rdp.error, client.error]);
/**
* Return to the credentials screen when an established session drops with an
* error, so the user can see what happened and retry.
*/
useEffect(() => {
if (connected.current && rdp.error) {
reset();
}
}, [rdp.error, reset]);
/**
* Close credentials modal when RDP is connected
+5 -3
View File
@@ -64,9 +64,11 @@ export default function PoliciesProvider({ children }: Props) {
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const sources = rule.sourceResource
? undefined
: await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const destinations = rule.destinationResource
? undefined
+1 -1
View File
@@ -21,7 +21,7 @@ const ENDPOINT_EXISTS_ERROR_CODES = [401, 403, 405];
* activation be picked up without forcing the user to clear storage.
*/
const LICENSE_CACHE_PREFIX = "netbird-licensed:";
const LICENSE_CACHE_TTL_MS = 60 * 60 * 1000;
const LICENSE_CACHE_TTL_MS = 60 * 10 * 1000;
const licenseCacheKey = () => `${LICENSE_CACHE_PREFIX}${loadConfig().apiOrigin}`;
+8 -9
View File
@@ -70,8 +70,8 @@ export function IntegrationCard<T>({
)}
>
<div className={"flex flex-col gap-4"}>
<div className={"flex justify-between"}>
<div className={"flex gap-4"}>
<div className={"flex justify-between gap-3"}>
<div className={"flex gap-4 min-w-0"}>
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
@@ -84,25 +84,24 @@ export function IntegrationCard<T>({
priority={true}
/>
</div>
<div>
<h3 className={""}>{name}</h3>
<div className={"min-w-0"}>
<h3 className={"truncate"}>{name}</h3>
<InlineLink
href={url.href}
target={"_blank"}
className={"text-sm font-light"}
className={"text-sm font-light min-w-0 max-w-full"}
variant={"faded"}
>
{url.title}
<ExternalLinkIcon size={12} />
<span className={"truncate"}>{url.title}</span>
<ExternalLinkIcon size={12} className={"shrink-0"} />
</InlineLink>
</div>
</div>
{!hideSwitch && (
<div className={"flex items-center"}>
<div className={"flex items-center shrink-0"}>
<ToggleSwitch
checked={switchState}
onCheckedChange={onEnabledChange}
className={"grow"}
/>
</div>
)}
+39 -39
View File
@@ -3,27 +3,31 @@ import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, MonitorIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
type Props = {
peer: Peer;
isDropdown?: boolean;
};
const RDP_SUPPORTED_OS = new Set([
OperatingSystem.WINDOWS,
OperatingSystem.LINUX,
OperatingSystem.FREEBSD,
]);
export const RDPButton = ({ peer, isDropdown = false }: Props) => {
const [modal, setModal] = useState(false);
const { permission } = usePermissions();
const os = getOperatingSystem(peer?.os);
if (!RDP_SUPPORTED_OS.has(os)) return null;
const disabled = !peer.connected || !permission.peers.update;
const hasPermission = permission.peers.update;
const isWindows = getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS;
const openRDPPage = () => {
window.open(
`/peer/rdp?id=${peer.id}`,
@@ -33,40 +37,36 @@ export const RDPButton = ({ peer, isDropdown = false }: Props) => {
};
return (
isWindows && (
<>
<div>
<RDPTooltip
disabled={!disabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
<div>
<RDPTooltip
disabled={!disabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>
{isDropdown ? (
<DropdownMenuItem
onClick={openRDPPage}
disabled={disabled}
className={"w-full"}
>
{isDropdown ? (
<DropdownMenuItem
onClick={openRDPPage}
disabled={disabled}
className={"w-full"}
>
<div className={"flex gap-3 items-center w-full"}>
<MonitorIcon size={14} className={"shrink-0"} />
RDP
</div>
</DropdownMenuItem>
) : (
<Button
variant="secondary"
size="sm"
onClick={openRDPPage}
disabled={disabled}
>
<MonitorIcon size={16} />
RDP
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</RDPTooltip>
</div>
</>
)
<div className={"flex gap-3 items-center w-full"}>
<MonitorIcon size={14} className={"shrink-0"} />
RDP
</div>
</DropdownMenuItem>
) : (
<Button
variant="secondary"
size="sm"
onClick={openRDPPage}
disabled={disabled}
>
<MonitorIcon size={16} />
RDP
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</RDPTooltip>
</div>
);
};
@@ -3,6 +3,8 @@ import { useCallback, useMemo, useState } from "react";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { Peer } from "@/interfaces/Peer";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
@@ -38,7 +40,11 @@ export const RDPCredentialsModal = ({
error,
loading,
}: Props) => {
const [username, setUsername] = useState("Administrator");
const defaultUsername =
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS
? "Administrator"
: "root";
const [username, setUsername] = useState(defaultUsername);
const [password, setPassword] = useState("");
const [port, setPort] = useState("3389");
@@ -2,13 +2,18 @@ export interface IronRDPModule {
SessionBuilder: new () => SessionBuilder;
DesktopSize: new (width: number, height: number) => DesktopSize;
ClipboardData?: new () => ClipboardData;
Extension?: new (ident: string, value: unknown) => Extension;
default?: () => Promise<void>;
init?: () => Promise<void>;
setup?: (logLevel: string) => void;
}
interface DesktopSize {
width: number;
height: number;
}
interface Extension {
free(): void;
}
interface SessionBuilder {
username(user: string): SessionBuilder;
password(pwd: string): SessionBuilder;
@@ -18,6 +23,7 @@ interface SessionBuilder {
renderCanvas(canvas: HTMLCanvasElement): SessionBuilder;
proxyAddress(url: string): SessionBuilder;
authToken(token: string): SessionBuilder;
extension(ext: Extension): SessionBuilder;
setCursorStyleCallback(cb: (style: string) => void): void;
setCursorStyleCallbackContext(ctx: unknown): void;
remoteClipboardChangedCallback(cb: (data: ClipboardData) => void): void;
@@ -28,6 +34,13 @@ export interface RDPSession {
run(): Promise<TerminationInfo>;
shutdown(): void;
sendInput(input: unknown): void;
resize(
width: number,
height: number,
scaleFactor?: number | null,
physicalWidth?: number | null,
physicalHeight?: number | null,
): void;
onClipboardPaste?(content: ClipboardData): Promise<void>;
}
interface TerminationInfo {
@@ -67,6 +80,30 @@ declare global {
const IRON_RDP_PKG = "/ironrdp-pkg/ironrdp_web.js";
// IronErrorKind values as exposed by the IronRDP wasm module's kind() method,
// indexed by the enum's numeric value.
const IRON_ERROR_KIND_NAMES = [
"General",
"WrongPassword",
"LogonFailure",
"AccessDenied",
"RDCleanPath",
"ProxyConnect",
"NegotiationFailure",
] as const;
// User-facing message per error kind. "General" is intentionally absent: it
// carries no structured detail, so its reason is read from the backtrace.
const IRON_ERROR_KIND_MESSAGES: Record<string, string> = {
WrongPassword: "Incorrect username or password.",
LogonFailure: "Login failed. Check your username and password.",
AccessDenied: "The remote host denied access.",
RDCleanPath: "Could not establish the RDP connection to the host.",
ProxyConnect: "Could not reach the remote host.",
NegotiationFailure:
"RDP negotiation failed. The host may not support the required security protocol.",
};
export class IronRDPWASMBridge {
private ironrdp: IronRDPModule | null = null;
private initialized = false;
@@ -91,6 +128,13 @@ export class IronRDPWASMBridge {
await ironrdpModule.init();
}
}
if (ironrdpModule.setup) {
try {
ironrdpModule.setup("info");
} catch (e) {
console.warn("IronRDP log setup failed:", e);
}
}
this.ironrdp = ironrdpModule;
this.initialized = true;
if (window.onIronRDPReady) {
@@ -112,6 +156,8 @@ export class IronRDPWASMBridge {
netbirdClient?: {
createRDPProxy: (hostname: string, port: string) => Promise<string>;
},
onSessionEnd?: (error: string | null, sessionId: string) => void,
enableDisplayControl = false,
): Promise<string> {
if (!this.initialized) {
await this.initialize();
@@ -144,6 +190,15 @@ export class IronRDPWASMBridge {
config.height,
);
builder.desktopSize(desktopSize);
// Enable the Display Control dynamic channel so the desktop can be
// resized in-session (Session.resize) instead of reconnecting. Gated to
// hosts known to handle it (Windows); xrdp mishandles the reactivation.
if (enableDisplayControl && this.ironrdp.Extension) {
builder.extension(
new this.ironrdp.Extension("display_control", true),
);
}
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
@@ -172,7 +227,7 @@ export class IronRDPWASMBridge {
if (enableClipboard) {
this.startClipboardEventListeners();
}
this.startSession(session, sessionId);
this.startSession(session, sessionId, onSessionEnd);
return sessionId;
} catch (error) {
console.error(`IronRDP connection failed:`, error);
@@ -193,16 +248,22 @@ export class IronRDPWASMBridge {
});
}
private startSession(session: RDPSession, sessionId: string): void {
private startSession(
session: RDPSession,
sessionId: string,
onSessionEnd?: (error: string | null, sessionId: string) => void,
): void {
session
.run()
.then((termInfo) => {
this.cleanupSession(session, sessionId);
onSessionEnd?.(null, sessionId);
})
.catch((err) => {
console.error("IronRDP session error:", err);
this.logIronError(err);
this.cleanupSession(session, sessionId);
throw Error(err);
onSessionEnd?.(this.getReadableError(err), sessionId);
});
}
private cleanupSession(session: RDPSession, sessionId: string): void {
@@ -282,11 +343,24 @@ export class IronRDPWASMBridge {
private getReadableError(error: unknown): string {
const ironError = error as any;
if (typeof ironError?.backtrace === "function") {
if (ironError && ironError.__wbg_ptr) {
try {
const formatted = this.formatRDCleanPathError(ironError.backtrace());
if (formatted.startsWith("Connection failed:")) {
return formatted;
if (typeof ironError.kind === "function") {
const kindName = IRON_ERROR_KIND_NAMES[ironError.kind()];
if (kindName && IRON_ERROR_KIND_MESSAGES[kindName]) {
return IRON_ERROR_KIND_MESSAGES[kindName];
}
}
if (typeof ironError.backtrace === "function") {
const backtrace = ironError.backtrace();
const formatted = this.formatRDCleanPathError(backtrace);
if (formatted.startsWith("Connection failed:")) {
return formatted;
}
const reason = this.extractIronReason(backtrace);
if (reason) {
return `RDP session error: ${reason}`;
}
}
} catch {
// Fall through to generic handling below.
@@ -298,6 +372,19 @@ export class IronRDPWASMBridge {
return "RDP connection failed";
}
// extractIronReason pulls the human-readable reason out of an IronRDP
// backtrace like `[IO channel] reason: unhandled PDU: "Update PDU"`. The
// wasm error has no structured field for this, so General-kind errors can
// only expose it as free text.
private extractIronReason(backtrace: string): string {
if (!backtrace) return "";
const reasonMatch = backtrace.match(/reason:\s*(.+)/);
if (reasonMatch) {
return reasonMatch[1].trim();
}
return backtrace.replace(/^\[[^\]]*\]\s*/, "").trim();
}
private logIronError(error: unknown): void {
const ironError = error as any;
if (!ironError || !ironError.__wbg_ptr) return;
@@ -310,16 +397,7 @@ export class IronRDPWASMBridge {
}
if (ironError.kind) {
const errorKind = ironError.kind();
const errorKindNames = [
"General",
"WrongPassword",
"LogonFailure",
"AccessDenied",
"RDCleanPath",
"ProxyConnect",
"NegotiationFailure",
];
const errorKindName = errorKindNames[errorKind] || "Unknown";
const errorKindName = IRON_ERROR_KIND_NAMES[errorKind] || "Unknown";
console.error("IronRDP error kind:", errorKindName, `(${errorKind})`);
}
} catch (e) {
@@ -330,6 +408,12 @@ export class IronRDPWASMBridge {
return this.sessions.get(sessionId) || null;
}
resize(sessionId: string, width: number, height: number): void {
const session = this.sessions.get(sessionId);
if (!session || typeof session.resize !== "function") return;
session.resize(width, height);
}
disconnect(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
@@ -13,6 +13,10 @@ interface RDPConfig {
domain?: string;
width?: number;
height?: number;
// dynamicResize enables the Display Control channel and in-session resize.
// Only reliable against Windows hosts; xrdp mishandles the reactivation, so
// non-Windows peers fall back to reconnect-on-resize.
dynamicResize?: boolean;
}
export interface RDPCredentials {
@@ -186,6 +190,14 @@ export const useRemoteDesktop = (client: any) => {
canvas,
true,
client.client,
(sessionError: string | null, endedSessionId: string) => {
// Ignore terminations from superseded sessions (e.g. a resize
// reconnect) so they can't tear down a newer connection.
if (endedSessionId !== session.current?.id) return;
resetState();
if (sessionError) setError(sessionError);
},
rdpConfig.dynamicResize ?? false,
);
// Store the ironrdp module and session for the input handler hook
@@ -265,7 +277,9 @@ export const useRemoteDesktop = (client: any) => {
}, []);
/**
* Handle window resize events - reconnect with new dimensions
* Handle window resize events. Hosts that support the Display Control channel
* (Windows) resize the live session in place; others reconnect with the new
* dimensions.
*/
useEffect(() => {
const handleResize = () => {
@@ -279,6 +293,31 @@ export const useRemoteDesktop = (client: any) => {
clearTimeout(resizeTimeoutRef.current);
}
if (lastConnectedConfigRef.current.dynamicResize) {
// In-session resize via Display Control, no reconnect.
resizeTimeoutRef.current = setTimeout(() => {
const width = window.innerWidth;
const height = window.innerHeight;
if (canvasRef.current) {
canvasRef.current.width = width;
canvasRef.current.height = height;
}
if (session.current && client?.ironRDPBridge) {
client.ironRDPBridge.resize(session.current.id, width, height);
}
lastConnectedConfigRef.current = {
...lastConnectedConfigRef.current!,
width,
height,
};
canvasRef?.current?.focus();
}, 200);
return;
}
setIsResizing(true);
// Debounce resize handling for 1 second
@@ -317,7 +356,7 @@ export const useRemoteDesktop = (client: any) => {
clearTimeout(resizeTimeoutRef.current);
}
};
}, [status, connect]);
}, [status, connect, client]);
/**
* Auto accept certificate if previously accepted (for reconnects)