Removed RDP in favor of VNC / Made WireGuard Tunnel Persistent

This commit is contained in:
2026-02-05 23:05:23 -07:00
parent 287d3b1cf7
commit 0d40ca6edb
35 changed files with 2207 additions and 1400 deletions

View File

@@ -18,8 +18,8 @@
"@mui/x-tree-view": "8.10.0",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"@novnc/novnc": "^1.4.0",
"dayjs": "1.11.18",
"guacamole-common-js": "1.5.0",
"normalize.css": "8.0.1",
"prismjs": "1.30.0",
"react-simple-code-editor": "0.13.1",

View File

@@ -43,7 +43,7 @@ import Editor from "react-simple-code-editor";
import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
import ReverseTunnelPowershell from "./ReverseTunnel/Powershell.jsx";
import ReverseTunnelRdp from "./ReverseTunnel/RDP.jsx";
import ReverseTunnelVnc from "./ReverseTunnel/VNC.jsx";
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -85,12 +85,6 @@ const buildVpnGroups = (shellPort) => {
description: "Web terminal access over the VPN tunnel.",
ports: [normalizedShell],
},
{
key: "rdp",
label: "RDP",
description: "Remote Desktop (TCP 3389).",
ports: [3389],
},
{
key: "winrm",
label: "WinRM",
@@ -121,7 +115,7 @@ const TOP_TABS = [
{ key: "activity", label: "Activity History", icon: ListAltRoundedIcon },
{ key: "advanced", label: "Advanced Config", icon: TuneRoundedIcon },
{ key: "shell", label: "Remote Shell", icon: TerminalRoundedIcon },
{ key: "rdp", label: "Remote Desktop", icon: DesktopWindowsRoundedIcon },
{ key: "vnc", label: "Remote Desktop (VNC)", icon: DesktopWindowsRoundedIcon },
];
const myTheme = themeQuartz.withParams({
@@ -1542,7 +1536,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
minHeight: 0,
}}
>
<ReverseTunnelRdp device={tunnelDevice} />
<ReverseTunnelVnc device={tunnelDevice} />
</Box>
);

View File

@@ -96,7 +96,6 @@ export default function ReverseTunnelPowershell({ device }) {
const localSocketRef = useRef(false);
const terminalRef = useRef(null);
const agentIdRef = useRef("");
const tunnelIdRef = useRef("");
const agentId = useMemo(() => {
return (
@@ -115,9 +114,6 @@ export default function ReverseTunnelPowershell({ device }) {
agentIdRef.current = agentId;
}, [agentId]);
useEffect(() => {
tunnelIdRef.current = tunnel?.tunnel_id || "";
}, [tunnel?.tunnel_id]);
const ensureSocket = useCallback(() => {
if (socketRef.current) return socketRef.current;
@@ -181,15 +177,14 @@ export default function ReverseTunnelPowershell({ device }) {
scrollToBottom();
}, [output, scrollToBottom]);
const stopTunnel = useCallback(async (reason = "operator_disconnect") => {
const disconnectShell = useCallback(async (reason = "operator_disconnect") => {
const currentAgentId = agentIdRef.current;
if (!currentAgentId) return;
const currentTunnelId = tunnelIdRef.current;
try {
await fetch("/api/tunnel/disconnect", {
method: "DELETE",
await fetch("/api/shell/disconnect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }),
body: JSON.stringify({ agent_id: currentAgentId, reason }),
});
} catch {
// best-effort
@@ -206,14 +201,14 @@ export default function ReverseTunnelPowershell({ device }) {
setStatusMessage("");
try {
await closeShell();
await stopTunnel("operator_disconnect");
await disconnectShell("operator_disconnect");
} finally {
setTunnel(null);
setShellState("closed");
setSessionState("idle");
setLoading(false);
}
}, [closeShell, stopTunnel]);
}, [closeShell, disconnectShell]);
useEffect(() => {
const socket = ensureSocket();
@@ -250,77 +245,39 @@ export default function ReverseTunnelPowershell({ device }) {
useEffect(() => {
return () => {
closeShell();
stopTunnel("component_unmount");
disconnectShell("component_unmount");
};
}, [closeShell, stopTunnel]);
}, [closeShell, disconnectShell]);
const requestTunnel = useCallback(async () => {
if (!agentId) {
setStatusMessage("Agent ID is required to connect.");
setStatusMessage("Agent ID is required to establish.");
return;
}
setLoading(true);
setStatusMessage("");
try {
try {
const readinessResp = await fetch(
`/api/tunnel/status?agent_id=${encodeURIComponent(agentId)}`
);
const readinessData = await readinessResp.json().catch(() => ({}));
if (readinessResp.ok && readinessData?.agent_socket !== true) {
await handleAgentOnboarding();
return;
}
} catch {
// best-effort readiness check
}
setSessionState("connecting");
setShellState("opening");
const resp = await fetch("/api/tunnel/connect", {
const resp = await fetch("/api/shell/establish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: agentId }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
if (data?.error === "agent_socket_missing") {
await handleAgentOnboarding();
return;
}
const detail = data?.detail ? `: ${data.detail}` : "";
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
}
tunnelIdRef.current = data?.tunnel_id || "";
const waitForTunnelReady = async () => {
const deadline = Date.now() + 60000;
let lastError = "";
while (Date.now() < deadline) {
const statusResp = await fetch(
`/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1`
);
const statusData = await statusResp.json().catch(() => ({}));
if (statusData?.error === "agent_socket_missing" || (statusResp.ok && statusData?.agent_socket === false)) {
await handleAgentOnboarding();
await stopTunnel("agent_onboarding_pending");
return null;
}
if (statusResp.ok && statusData?.status === "up") {
const agentSocket = statusData?.agent_socket;
const agentReady = agentSocket === undefined ? true : Boolean(agentSocket);
if (agentReady) {
return statusData;
}
setStatusMessage("Waiting for agent VPN socket to register...");
} else if (statusData?.error) {
lastError = statusData.error;
}
await sleep(2000);
}
throw new Error(lastError || "Tunnel not ready");
};
const statusData = await waitForTunnelReady();
if (!statusData) {
if (data?.agent_socket === false) {
await handleAgentOnboarding();
return;
}
setTunnel({ ...data, ...statusData });
setTunnel(data);
const socket = ensureSocket();
const openShellWithRetry = async () => {
@@ -335,7 +292,6 @@ export default function ReverseTunnelPowershell({ device }) {
}
if (openResp.error === "agent_socket_missing") {
await handleAgentOnboarding();
await stopTunnel("agent_onboarding_pending");
return null;
}
lastError = openResp.error;
@@ -359,7 +315,7 @@ export default function ReverseTunnelPowershell({ device }) {
} finally {
setLoading(false);
}
}, [agentId, ensureSocket, handleAgentOnboarding, stopTunnel]);
}, [agentId, ensureSocket, handleAgentOnboarding]);
const handleSend = useCallback(
async (text) => {
@@ -414,7 +370,7 @@ export default function ReverseTunnelPowershell({ device }) {
disabled={loading || (!isConnected && !agentId)}
onClick={isConnected ? handleDisconnect : requestTunnel}
>
{isConnected ? "Disconnect" : "Connect"}
{isConnected ? "Disconnect" : "Establish"}
</Button>
<Stack direction="row" spacing={1}>
{sessionChips.map((chip) => (
@@ -510,7 +466,7 @@ export default function ReverseTunnelPowershell({ device }) {
size="small"
value={input}
disabled={!isConnected}
placeholder={isConnected ? "Enter PowerShell command and press Enter" : "Connect to start sending commands"}
placeholder={isConnected ? "Enter PowerShell command and press Enter" : "Establish to start sending commands"}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {

View File

@@ -1,552 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Button,
Chip,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
LinearProgress,
} from "@mui/material";
import {
DesktopWindowsRounded as DesktopIcon,
PlayArrowRounded as PlayIcon,
StopRounded as StopIcon,
LinkRounded as LinkIcon,
LanRounded as IpIcon,
} from "@mui/icons-material";
import Guacamole from "guacamole-common-js";
const MAGIC_UI = {
panelBorder: "rgba(148, 163, 184, 0.35)",
textMuted: "#94a3b8",
textBright: "#e2e8f0",
accentA: "#7dd3fc",
accentB: "#c084fc",
accentC: "#34d399",
};
const gradientButtonSx = {
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
color: "#0b1220",
borderRadius: 999,
textTransform: "none",
px: 2.2,
minWidth: 120,
"&:hover": {
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
},
};
const PROTOCOLS = [{ value: "rdp", label: "RDP" }];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function normalizeText(value) {
if (value == null) return "";
try {
return String(value).trim();
} catch {
return "";
}
}
export default function ReverseTunnelRdp({ device }) {
const [sessionState, setSessionState] = useState("idle");
const [statusMessage, setStatusMessage] = useState("");
const [loading, setLoading] = useState(false);
const [tunnel, setTunnel] = useState(null);
const [protocol, setProtocol] = useState("rdp");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const containerRef = useRef(null);
const displayRef = useRef(null);
const clientRef = useRef(null);
const tunnelRef = useRef(null);
const mouseRef = useRef(null);
const agentIdRef = useRef("");
const tunnelIdRef = useRef("");
const bumpTimerRef = useRef(null);
const agentId = useMemo(() => {
return (
normalizeText(device?.agent_id) ||
normalizeText(device?.agentId) ||
normalizeText(device?.agent_guid) ||
normalizeText(device?.agentGuid) ||
normalizeText(device?.id) ||
normalizeText(device?.guid) ||
normalizeText(device?.summary?.agent_id) ||
""
);
}, [device]);
useEffect(() => {
agentIdRef.current = agentId;
}, [agentId]);
useEffect(() => {
tunnelIdRef.current = tunnel?.tunnel_id || "";
}, [tunnel?.tunnel_id]);
const notifyAgentOnboarding = useCallback(async () => {
try {
await fetch("/api/notifications/notify", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
title: "Agent Onboarding Underway",
message:
"Please wait for the agent to finish onboarding into Borealis. It takes about 1 minute to finish the process.",
icon: "info",
variant: "info",
}),
});
} catch {
/* ignore notification transport errors */
}
}, []);
const handleAgentOnboarding = useCallback(async () => {
await notifyAgentOnboarding();
setStatusMessage("Agent Onboarding Underway.");
setSessionState("idle");
setTunnel(null);
}, [notifyAgentOnboarding]);
const teardownDisplay = useCallback(() => {
try {
const client = clientRef.current;
if (client) {
client.disconnect();
}
} catch {
/* ignore */
}
clientRef.current = null;
tunnelRef.current = null;
mouseRef.current = null;
const host = displayRef.current;
if (host) {
host.innerHTML = "";
}
}, []);
const stopTunnel = useCallback(async (reason = "operator_disconnect") => {
const currentAgentId = agentIdRef.current;
if (!currentAgentId) return;
const currentTunnelId = tunnelIdRef.current;
try {
await fetch("/api/tunnel/disconnect", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }),
});
} catch {
// best-effort
}
}, []);
const clearBumpTimer = useCallback(() => {
if (bumpTimerRef.current) {
clearInterval(bumpTimerRef.current);
bumpTimerRef.current = null;
}
}, []);
const startBumpTimer = useCallback(
(currentAgentId) => {
clearBumpTimer();
if (!currentAgentId) return;
bumpTimerRef.current = setInterval(async () => {
try {
await fetch(`/api/tunnel/connect/status?agent_id=${encodeURIComponent(currentAgentId)}&bump=1`);
} catch {
/* ignore */
}
}, 60000);
},
[clearBumpTimer]
);
const handleDisconnect = useCallback(async () => {
setLoading(true);
setStatusMessage("");
clearBumpTimer();
try {
teardownDisplay();
await stopTunnel("operator_disconnect");
} finally {
setTunnel(null);
setSessionState("idle");
setLoading(false);
}
}, [clearBumpTimer, stopTunnel, teardownDisplay]);
const scaleToFit = useCallback(() => {
const client = clientRef.current;
const container = containerRef.current;
if (!client || !container) return;
const display = client.getDisplay();
const displayWidth = display.getWidth();
const displayHeight = display.getHeight();
const bounds = container.getBoundingClientRect();
if (!displayWidth || !displayHeight || !bounds.width || !bounds.height) return;
const scale = Math.min(bounds.width / displayWidth, bounds.height / displayHeight);
display.scale(scale);
}, []);
useEffect(() => {
const handleResize = () => scaleToFit();
window.addEventListener("resize", handleResize);
let observer = null;
if (typeof ResizeObserver !== "undefined" && containerRef.current) {
observer = new ResizeObserver(() => scaleToFit());
observer.observe(containerRef.current);
}
return () => {
window.removeEventListener("resize", handleResize);
if (observer) observer.disconnect();
};
}, [scaleToFit]);
useEffect(() => {
return () => {
clearBumpTimer();
teardownDisplay();
stopTunnel("component_unmount");
};
}, [clearBumpTimer, stopTunnel, teardownDisplay]);
const requestTunnel = useCallback(async () => {
if (!agentId) {
setStatusMessage("Agent ID is required to connect.");
return null;
}
setLoading(true);
setStatusMessage("");
try {
try {
const readinessResp = await fetch(`/api/tunnel/status?agent_id=${encodeURIComponent(agentId)}`);
const readinessData = await readinessResp.json().catch(() => ({}));
if (readinessResp.ok && readinessData?.agent_socket !== true) {
await handleAgentOnboarding();
return null;
}
} catch {
// best-effort readiness check
}
setSessionState("connecting");
const resp = await fetch("/api/tunnel/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: agentId }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const detail = data?.detail ? `: ${data.detail}` : "";
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
}
tunnelIdRef.current = data?.tunnel_id || "";
const waitForTunnelReady = async () => {
const deadline = Date.now() + 60000;
let lastError = "";
while (Date.now() < deadline) {
const statusResp = await fetch(
`/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1`
);
const statusData = await statusResp.json().catch(() => ({}));
if (statusData?.error === "agent_socket_missing" || (statusResp.ok && statusData?.agent_socket === false)) {
await handleAgentOnboarding();
await stopTunnel("agent_onboarding_pending");
return null;
}
if (statusResp.ok && statusData?.status === "up") {
const agentSocket = statusData?.agent_socket;
const agentReady = agentSocket === undefined ? true : Boolean(agentSocket);
if (agentReady) {
return statusData;
}
setStatusMessage("Waiting for agent VPN socket to register...");
} else if (statusData?.error) {
lastError = statusData.error;
}
await sleep(2000);
}
throw new Error(lastError || "Tunnel not ready");
};
const statusData = await waitForTunnelReady();
if (!statusData) {
return null;
}
setTunnel({ ...data, ...statusData });
startBumpTimer(agentId);
return statusData;
} catch (err) {
setSessionState("error");
setStatusMessage(String(err.message || err));
return null;
} finally {
setLoading(false);
}
}, [agentId, handleAgentOnboarding, startBumpTimer, stopTunnel]);
const openRdpSession = useCallback(
async () => {
const currentAgentId = agentIdRef.current;
if (!currentAgentId) return;
const payload = {
agent_id: currentAgentId,
protocol,
username: username.trim(),
password,
};
const resp = await fetch("/api/rdp/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const detail = data?.detail ? `: ${data.detail}` : "";
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
}
const token = data?.token;
const wsUrl = data?.ws_url;
if (!token || !wsUrl) {
throw new Error("RDP session unavailable.");
}
const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`;
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
const client = new Guacamole.Client(tunnel);
const displayHost = displayRef.current;
tunnel.onerror = (status) => {
setStatusMessage(status?.message || "RDP tunnel error.");
};
client.onerror = (status) => {
setStatusMessage(status?.message || "RDP client error.");
};
client.onstatechange = (state) => {
if (state === Guacamole.Client.State.CONNECTED) {
setSessionState("connected");
setStatusMessage("");
} else if (state === Guacamole.Client.State.DISCONNECTED) {
setSessionState("idle");
}
};
client.onresize = () => {
scaleToFit();
};
if (displayHost) {
displayHost.innerHTML = "";
displayHost.appendChild(client.getDisplay().getElement());
}
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
mouse.onmousemove = (state) => client.sendMouseState(state);
mouse.onmousedown = (state) => client.sendMouseState(state);
mouse.onmouseup = (state) => client.sendMouseState(state);
clientRef.current = client;
tunnelRef.current = tunnel;
mouseRef.current = mouse;
client.connect();
scaleToFit();
setStatusMessage("Connecting to RDP...");
},
[password, protocol, scaleToFit, username]
);
const handleConnect = useCallback(async () => {
if (sessionState === "connected") return;
setStatusMessage("");
setSessionState("connecting");
try {
const tunnelReady = await requestTunnel();
if (!tunnelReady) {
return;
}
await openRdpSession();
} catch (err) {
setSessionState("error");
setStatusMessage(String(err.message || err));
}
}, [openRdpSession, requestTunnel, sessionState]);
const isConnected = sessionState === "connected";
const sessionChips = [
tunnel?.tunnel_id
? {
label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`,
color: MAGIC_UI.accentB,
icon: <LinkIcon sx={{ fontSize: 18 }} />,
}
: null,
tunnel?.virtual_ip
? {
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
color: MAGIC_UI.accentA,
icon: <IpIcon sx={{ fontSize: 18 }} />,
}
: null,
].filter(Boolean);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", md: "center" }}>
<Button
size="small"
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
sx={gradientButtonSx}
disabled={loading || (!isConnected && !agentId)}
onClick={isConnected ? handleDisconnect : handleConnect}
>
{isConnected ? "Disconnect" : "Connect"}
</Button>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", alignItems: "center" }}>
<FormControl
size="small"
sx={{
minWidth: 140,
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(12,18,35,0.9)",
color: MAGIC_UI.textBright,
borderRadius: 2,
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}}
>
<InputLabel>Protocol</InputLabel>
<Select
label="Protocol"
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
disabled={isConnected}
>
{PROTOCOLS.map((item) => (
<MenuItem key={item.value} value={item.value}>
{item.label}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isConnected}
sx={{
minWidth: 180,
input: { color: MAGIC_UI.textBright },
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(12,18,35,0.9)",
borderRadius: 2,
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}}
/>
<TextField
size="small"
type="password"
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isConnected}
sx={{
minWidth: 180,
input: { color: MAGIC_UI.textBright },
"& .MuiOutlinedInput-root": {
backgroundColor: "rgba(12,18,35,0.9)",
borderRadius: 2,
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
},
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}}
/>
</Stack>
<Stack direction="row" spacing={1}>
{sessionChips.map((chip) => (
<Chip
key={chip.label}
icon={chip.icon}
label={chip.label}
sx={{
borderRadius: 999,
color: chip.color,
border: `1px solid ${MAGIC_UI.panelBorder}`,
backgroundColor: "rgba(8,12,24,0.65)",
}}
/>
))}
</Stack>
</Stack>
<Box
sx={{
flexGrow: 1,
minHeight: 320,
display: "flex",
flexDirection: "column",
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background:
"linear-gradient(145deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 20%, rgba(125,211,252,0.08), transparent 35%)",
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
overflow: "hidden",
position: "relative",
}}
>
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
<Box
ref={containerRef}
sx={{
flexGrow: 1,
position: "relative",
backgroundColor: "rgba(2,6,20,0.9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Box ref={displayRef} sx={{ width: "100%", height: "100%" }} />
{!isConnected ? (
<Stack spacing={1} sx={{ position: "absolute", alignItems: "center" }}>
<DesktopIcon sx={{ color: MAGIC_UI.accentA, fontSize: 40 }} />
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Connect to start the remote desktop session.
</Typography>
</Stack>
) : null}
</Box>
</Box>
<Stack spacing={0.3} sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Session: {isConnected ? "Active" : sessionState}
</Typography>
{statusMessage ? (
<Typography variant="body2" sx={{ color: sessionState === "error" ? "#ff7b89" : MAGIC_UI.textMuted }}>
{statusMessage}
</Typography>
) : null}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,351 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Button,
Chip,
Stack,
Typography,
LinearProgress,
} from "@mui/material";
import {
DesktopWindowsRounded as DesktopIcon,
PlayArrowRounded as PlayIcon,
StopRounded as StopIcon,
LinkRounded as LinkIcon,
LanRounded as IpIcon,
} from "@mui/icons-material";
import RFB from "@novnc/novnc/lib/rfb";
const MAGIC_UI = {
panelBorder: "rgba(148, 163, 184, 0.35)",
textMuted: "#94a3b8",
textBright: "#e2e8f0",
accentA: "#7dd3fc",
accentB: "#c084fc",
accentC: "#34d399",
};
const gradientButtonSx = {
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
color: "#0b1220",
borderRadius: 999,
textTransform: "none",
px: 2.2,
minWidth: 120,
"&:hover": {
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
},
};
function normalizeText(value) {
if (value == null) return "";
try {
return String(value).trim();
} catch {
return "";
}
}
export default function ReverseTunnelVnc({ device }) {
const [sessionState, setSessionState] = useState("idle");
const [statusMessage, setStatusMessage] = useState("");
const [loading, setLoading] = useState(false);
const [tunnel, setTunnel] = useState(null);
const containerRef = useRef(null);
const displayRef = useRef(null);
const rfbRef = useRef(null);
const agentIdRef = useRef("");
const agentId = useMemo(() => {
return (
normalizeText(device?.agent_id) ||
normalizeText(device?.agentId) ||
normalizeText(device?.agent_guid) ||
normalizeText(device?.agentGuid) ||
normalizeText(device?.id) ||
normalizeText(device?.guid) ||
normalizeText(device?.summary?.agent_id) ||
""
);
}, [device]);
useEffect(() => {
agentIdRef.current = agentId;
}, [agentId]);
const notifyAgentOnboarding = useCallback(async () => {
try {
await fetch("/api/notifications/notify", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
title: "Agent Onboarding Underway",
message:
"Please wait for the agent to finish onboarding into Borealis. It takes about 1 minute to finish the process.",
icon: "info",
variant: "info",
}),
});
} catch {
/* ignore notification transport errors */
}
}, []);
const handleAgentOnboarding = useCallback(async () => {
await notifyAgentOnboarding();
setStatusMessage("Agent Onboarding Underway.");
setSessionState("idle");
setTunnel(null);
}, [notifyAgentOnboarding]);
const teardownDisplay = useCallback(() => {
try {
const client = rfbRef.current;
if (client) {
client.disconnect();
}
} catch {
/* ignore */
}
rfbRef.current = null;
const host = displayRef.current;
if (host) {
host.innerHTML = "";
}
}, []);
const disconnectVnc = useCallback(async (reason = "operator_disconnect") => {
const currentAgentId = agentIdRef.current;
if (!currentAgentId) return;
try {
await fetch("/api/vnc/disconnect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: currentAgentId, reason }),
});
} catch {
// best-effort
}
}, []);
const handleDisconnect = useCallback(async () => {
setLoading(true);
setStatusMessage("");
try {
teardownDisplay();
await disconnectVnc("operator_disconnect");
} finally {
setTunnel(null);
setSessionState("idle");
setLoading(false);
}
}, [disconnectVnc, teardownDisplay]);
useEffect(() => {
return () => {
teardownDisplay();
disconnectVnc("component_unmount");
};
}, [disconnectVnc, teardownDisplay]);
const requestTunnel = useCallback(async () => {
if (!agentId) {
setStatusMessage("Agent ID is required to establish.");
return null;
}
setLoading(true);
setStatusMessage("");
try {
setSessionState("connecting");
const resp = await fetch("/api/vnc/establish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: agentId }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
if (data?.error === "agent_socket_missing") {
await handleAgentOnboarding();
return null;
}
const detail = data?.detail ? `: ${data.detail}` : "";
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
}
setTunnel({
tunnel_id: data?.tunnel_id,
virtual_ip: data?.virtual_ip,
});
return data;
} catch (err) {
setSessionState("error");
setStatusMessage(String(err.message || err));
return null;
} finally {
setLoading(false);
}
}, [agentId, handleAgentOnboarding]);
const openVncSession = useCallback(async (data) => {
const token = data?.token;
const wsUrl = data?.ws_url;
const vncPassword = data?.vnc_password || "";
if (!token || !wsUrl) {
throw new Error("VNC session unavailable.");
}
const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`;
const displayHost = displayRef.current;
if (!displayHost) {
throw new Error("VNC display container missing.");
}
displayHost.innerHTML = "";
const rfb = new RFB(displayHost, tunnelUrl, {
credentials: { password: vncPassword },
});
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.clipViewport = true;
rfb.addEventListener("connect", () => {
setSessionState("connected");
setStatusMessage("");
});
rfb.addEventListener("disconnect", () => {
setSessionState("idle");
rfbRef.current = null;
});
rfb.addEventListener("securityfailure", (evt) => {
const detail = evt?.detail?.reason ? ` (${evt.detail.reason})` : "";
setSessionState("error");
setStatusMessage(`VNC authentication failed${detail}.`);
});
rfb.addEventListener("credentialsrequired", () => {
try {
rfb.sendCredentials({ password: vncPassword });
} catch {
// ignore
}
});
rfbRef.current = rfb;
setStatusMessage("Establishing VNC...");
}, []);
const handleConnect = useCallback(async () => {
if (sessionState === "connected") return;
setStatusMessage("");
setSessionState("connecting");
try {
const sessionData = await requestTunnel();
if (!sessionData) {
return;
}
await openVncSession(sessionData);
} catch (err) {
setSessionState("error");
setStatusMessage(String(err.message || err));
}
}, [openVncSession, requestTunnel, sessionState]);
const isConnected = sessionState === "connected";
const sessionChips = [
tunnel?.tunnel_id
? {
label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`,
color: MAGIC_UI.accentB,
icon: <LinkIcon sx={{ fontSize: 18 }} />,
}
: null,
tunnel?.virtual_ip
? {
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
color: MAGIC_UI.accentA,
icon: <IpIcon sx={{ fontSize: 18 }} />,
}
: null,
].filter(Boolean);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", md: "center" }}>
<Button
size="small"
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
sx={gradientButtonSx}
disabled={loading || (!isConnected && !agentId)}
onClick={isConnected ? handleDisconnect : handleConnect}
>
{isConnected ? "Disconnect" : "Establish"}
</Button>
<Stack direction="row" spacing={1}>
{sessionChips.map((chip) => (
<Chip
key={chip.label}
icon={chip.icon}
label={chip.label}
sx={{
borderRadius: 999,
color: chip.color,
border: `1px solid ${MAGIC_UI.panelBorder}`,
backgroundColor: "rgba(8,12,24,0.65)",
}}
/>
))}
</Stack>
</Stack>
<Box
sx={{
flexGrow: 1,
minHeight: 320,
display: "flex",
flexDirection: "column",
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background:
"linear-gradient(145deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 20%, rgba(125,211,252,0.08), transparent 35%)",
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
overflow: "hidden",
position: "relative",
}}
>
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
<Box
ref={containerRef}
sx={{
flexGrow: 1,
position: "relative",
backgroundColor: "rgba(2,6,20,0.9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<Box ref={displayRef} sx={{ width: "100%", height: "100%" }} />
{!isConnected ? (
<Stack spacing={1} sx={{ position: "absolute", alignItems: "center" }}>
<DesktopIcon sx={{ color: MAGIC_UI.accentA, fontSize: 40 }} />
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Establish to start the VNC session.
</Typography>
</Stack>
) : null}
</Box>
</Box>
<Stack spacing={0.3} sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Session: {isConnected ? "Active" : sessionState}
</Typography>
{statusMessage ? (
<Typography variant="body2" sx={{ color: sessionState === "error" ? "#ff7b89" : MAGIC_UI.textMuted }}>
{statusMessage}
</Typography>
) : null}
</Stack>
</Box>
);
}

View File

@@ -42,6 +42,14 @@ const httpsOptions = certPath && keyPath
export default defineConfig({
plugins: [react()],
esbuild: {
target: "es2022",
},
optimizeDeps: {
esbuildOptions: {
target: "es2022",
},
},
server: {
open: true,
host: true,
@@ -69,6 +77,7 @@ export default defineConfig({
outDir: 'build',
emptyOutDir: true,
chunkSizeWarningLimit: 1000,
target: 'es2022',
rollupOptions: {
output: {
// split each npm package into its own chunk