mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-18 18:35:48 -07:00
Overhaul of VPN Codebase
This commit is contained in:
@@ -8,13 +8,17 @@ import {
|
||||
Tab,
|
||||
Typography,
|
||||
Button,
|
||||
Switch,
|
||||
Chip,
|
||||
Divider,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions
|
||||
DialogActions,
|
||||
LinearProgress
|
||||
} from "@mui/material";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import StorageRoundedIcon from "@mui/icons-material/StorageRounded";
|
||||
@@ -23,6 +27,7 @@ import LanRoundedIcon from "@mui/icons-material/LanRounded";
|
||||
import AppsRoundedIcon from "@mui/icons-material/AppsRounded";
|
||||
import ListAltRoundedIcon from "@mui/icons-material/ListAltRounded";
|
||||
import TerminalRoundedIcon from "@mui/icons-material/TerminalRounded";
|
||||
import TuneRoundedIcon from "@mui/icons-material/TuneRounded";
|
||||
import SpeedRoundedIcon from "@mui/icons-material/SpeedRounded";
|
||||
import DeveloperBoardRoundedIcon from "@mui/icons-material/DeveloperBoardRounded";
|
||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
@@ -69,14 +74,51 @@ const SECTION_HEIGHTS = {
|
||||
network: 260,
|
||||
};
|
||||
|
||||
const buildVpnGroups = (shellPort) => {
|
||||
const normalizedShell = Number(shellPort) || 47001;
|
||||
return [
|
||||
{
|
||||
key: "shell",
|
||||
label: "Borealis PowerShell",
|
||||
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",
|
||||
description: "PowerShell/WinRM management (TCP 5985/5986).",
|
||||
ports: [5985, 5986],
|
||||
},
|
||||
{
|
||||
key: "vnc",
|
||||
label: "VNC",
|
||||
description: "Remote desktop streaming (TCP 5900).",
|
||||
ports: [5900],
|
||||
},
|
||||
{
|
||||
key: "webrtc",
|
||||
label: "WebRTC",
|
||||
description: "Real-time comms (UDP 3478).",
|
||||
ports: [3478],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const TOP_TABS = [
|
||||
{ label: "Device Summary", icon: InfoOutlinedIcon },
|
||||
{ label: "Storage", icon: StorageRoundedIcon },
|
||||
{ label: "Memory", icon: MemoryRoundedIcon },
|
||||
{ label: "Network", icon: LanRoundedIcon },
|
||||
{ label: "Installed Software", icon: AppsRoundedIcon },
|
||||
{ label: "Activity History", icon: ListAltRoundedIcon },
|
||||
{ label: "Remote Shell", icon: TerminalRoundedIcon },
|
||||
{ key: "summary", label: "Device Summary", icon: InfoOutlinedIcon },
|
||||
{ key: "storage", label: "Storage", icon: StorageRoundedIcon },
|
||||
{ key: "memory", label: "Memory", icon: MemoryRoundedIcon },
|
||||
{ key: "network", label: "Network", icon: LanRoundedIcon },
|
||||
{ key: "software", label: "Installed Software", icon: AppsRoundedIcon },
|
||||
{ key: "activity", label: "Activity History", icon: ListAltRoundedIcon },
|
||||
{ key: "advanced", label: "Advanced Config", icon: TuneRoundedIcon },
|
||||
{ key: "shell", label: "Remote Shell", icon: TerminalRoundedIcon },
|
||||
];
|
||||
|
||||
const myTheme = themeQuartz.withParams({
|
||||
@@ -286,6 +328,15 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||
const [assemblyNameMap, setAssemblyNameMap] = useState({});
|
||||
const [vpnLoading, setVpnLoading] = useState(false);
|
||||
const [vpnSaving, setVpnSaving] = useState(false);
|
||||
const [vpnError, setVpnError] = useState("");
|
||||
const [vpnSource, setVpnSource] = useState("default");
|
||||
const [vpnToggles, setVpnToggles] = useState({});
|
||||
const [vpnCustomPorts, setVpnCustomPorts] = useState([]);
|
||||
const [vpnDefaultPorts, setVpnDefaultPorts] = useState([]);
|
||||
const [vpnShellPort, setVpnShellPort] = useState(47001);
|
||||
const [vpnLoadedFor, setVpnLoadedFor] = useState("");
|
||||
// Snapshotted status for the lifetime of this page
|
||||
const [lockedStatus, setLockedStatus] = useState(() => {
|
||||
// Prefer status provided by the device list row if available
|
||||
@@ -655,6 +706,104 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
};
|
||||
}, [activityHostname, loadHistory]);
|
||||
|
||||
const applyVpnPorts = useCallback((ports, defaults, shellPort, source) => {
|
||||
const normalized = Array.isArray(ports) ? ports : [];
|
||||
const normalizedDefaults = Array.isArray(defaults) ? defaults : [];
|
||||
const numericPorts = normalized
|
||||
.map((p) => Number(p))
|
||||
.filter((p) => Number.isFinite(p) && p > 0);
|
||||
const numericDefaults = normalizedDefaults
|
||||
.map((p) => Number(p))
|
||||
.filter((p) => Number.isFinite(p) && p > 0);
|
||||
const effectiveShell = Number(shellPort) || 47001;
|
||||
const groups = buildVpnGroups(effectiveShell);
|
||||
const knownPorts = new Set(groups.flatMap((group) => group.ports));
|
||||
const allowedSet = new Set(numericPorts);
|
||||
const nextToggles = {};
|
||||
groups.forEach((group) => {
|
||||
nextToggles[group.key] = group.ports.every((port) => allowedSet.has(port));
|
||||
});
|
||||
const customPorts = numericPorts.filter((port) => !knownPorts.has(port));
|
||||
setVpnShellPort(effectiveShell);
|
||||
setVpnDefaultPorts(numericDefaults);
|
||||
setVpnCustomPorts(customPorts);
|
||||
setVpnToggles(nextToggles);
|
||||
setVpnSource(source || "default");
|
||||
}, []);
|
||||
|
||||
const loadVpnConfig = useCallback(async () => {
|
||||
if (!vpnAgentId) return;
|
||||
setVpnLoading(true);
|
||||
setVpnError("");
|
||||
setVpnLoadedFor(vpnAgentId);
|
||||
try {
|
||||
const resp = await fetch(`/api/device/vpn_config/${encodeURIComponent(vpnAgentId)}`);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
||||
const allowedPorts = Array.isArray(data?.allowed_ports) ? data.allowed_ports : [];
|
||||
const defaultPorts = Array.isArray(data?.default_ports) ? data.default_ports : [];
|
||||
const shellPort = data?.shell_port;
|
||||
applyVpnPorts(allowedPorts.length ? allowedPorts : defaultPorts, defaultPorts, shellPort, data?.source);
|
||||
setVpnLoadedFor(vpnAgentId);
|
||||
} catch (err) {
|
||||
setVpnError(String(err.message || err));
|
||||
} finally {
|
||||
setVpnLoading(false);
|
||||
}
|
||||
}, [applyVpnPorts, vpnAgentId]);
|
||||
|
||||
const saveVpnConfig = useCallback(async () => {
|
||||
if (!vpnAgentId) return;
|
||||
const ports = [];
|
||||
vpnPortGroups.forEach((group) => {
|
||||
if (vpnToggles[group.key]) {
|
||||
ports.push(...group.ports);
|
||||
}
|
||||
});
|
||||
vpnCustomPorts.forEach((port) => ports.push(port));
|
||||
const uniquePorts = Array.from(new Set(ports)).filter((p) => p > 0);
|
||||
if (!uniquePorts.length) {
|
||||
setVpnError("Enable at least one port before saving.");
|
||||
return;
|
||||
}
|
||||
setVpnSaving(true);
|
||||
setVpnError("");
|
||||
try {
|
||||
const resp = await fetch(`/api/device/vpn_config/${encodeURIComponent(vpnAgentId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowed_ports: uniquePorts }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
||||
const allowedPorts = Array.isArray(data?.allowed_ports) ? data.allowed_ports : uniquePorts;
|
||||
const defaultPorts = Array.isArray(data?.default_ports) ? data.default_ports : vpnDefaultPorts;
|
||||
applyVpnPorts(allowedPorts, defaultPorts, data?.shell_port || vpnShellPort, data?.source || "custom");
|
||||
} catch (err) {
|
||||
setVpnError(String(err.message || err));
|
||||
} finally {
|
||||
setVpnSaving(false);
|
||||
}
|
||||
}, [applyVpnPorts, vpnAgentId, vpnCustomPorts, vpnDefaultPorts, vpnPortGroups, vpnShellPort, vpnToggles]);
|
||||
|
||||
const resetVpnConfig = useCallback(() => {
|
||||
if (!vpnDefaultPorts.length) {
|
||||
setVpnError("No default ports available to reset.");
|
||||
return;
|
||||
}
|
||||
setVpnError("");
|
||||
applyVpnPorts(vpnDefaultPorts, vpnDefaultPorts, vpnShellPort, "default");
|
||||
}, [applyVpnPorts, vpnDefaultPorts, vpnShellPort]);
|
||||
|
||||
useEffect(() => {
|
||||
const advancedIndex = TOP_TABS.findIndex((item) => item.key === "advanced");
|
||||
if (advancedIndex < 0) return;
|
||||
if (tab !== advancedIndex) return;
|
||||
if (!vpnAgentId) return;
|
||||
if (vpnLoadedFor === vpnAgentId) return;
|
||||
loadVpnConfig();
|
||||
}, [loadVpnConfig, tab, vpnAgentId, vpnLoadedFor]);
|
||||
|
||||
// No explicit live recap tab; recaps are recorded into Activity History
|
||||
|
||||
const clearHistory = async () => {
|
||||
@@ -739,6 +888,19 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
);
|
||||
|
||||
const summary = details.summary || {};
|
||||
const vpnAgentId = useMemo(() => {
|
||||
return (
|
||||
meta.agentId ||
|
||||
summary.agent_id ||
|
||||
agent?.agent_id ||
|
||||
agent?.id ||
|
||||
device?.agent_id ||
|
||||
device?.agent_guid ||
|
||||
device?.id ||
|
||||
""
|
||||
);
|
||||
}, [agent?.agent_id, agent?.id, device?.agent_guid, device?.agent_id, device?.id, meta.agentId, summary.agent_id]);
|
||||
const vpnPortGroups = useMemo(() => buildVpnGroups(vpnShellPort), [vpnShellPort]);
|
||||
const tunnelDevice = useMemo(
|
||||
() => ({
|
||||
...(device || {}),
|
||||
@@ -876,7 +1038,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
const formatScriptType = useCallback((raw) => {
|
||||
const value = String(raw || "").toLowerCase();
|
||||
if (value === "ansible") return "Ansible Playbook";
|
||||
if (value === "reverse_tunnel") return "Reverse Tunnel";
|
||||
if (value === "reverse_tunnel" || value === "vpn_tunnel") return "Reverse VPN Tunnel";
|
||||
return "Script";
|
||||
}, []);
|
||||
|
||||
@@ -1368,6 +1530,150 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
</Box>
|
||||
);
|
||||
|
||||
const handleVpnToggle = useCallback((key, checked) => {
|
||||
setVpnToggles((prev) => ({ ...(prev || {}), [key]: checked }));
|
||||
setVpnSource("custom");
|
||||
}, []);
|
||||
|
||||
const renderAdvancedConfigTab = () => {
|
||||
const sourceLabel = vpnSource === "custom" ? "Custom overrides" : "Defaults";
|
||||
const showProgress = vpnLoading || vpnSaving;
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, flexGrow: 1, minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
background:
|
||||
"linear-gradient(160deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 10%, rgba(125,211,252,0.08), transparent 40%)",
|
||||
boxShadow: "0 25px 80px rgba(2,6,23,0.65)",
|
||||
p: { xs: 2, md: 3 },
|
||||
}}
|
||||
>
|
||||
{showProgress ? <LinearProgress color="info" sx={{ height: 3, mb: 2 }} /> : null}
|
||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2} alignItems={{ xs: "flex-start", md: "center" }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
|
||||
Reverse VPN Tunnel - Allowed Ports
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted, mt: 0.5 }}>
|
||||
Toggle which services the Engine can reach over the WireGuard tunnel for this device.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={sourceLabel}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
color: vpnSource === "custom" ? MAGIC_UI.accentA : MAGIC_UI.textMuted,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
backgroundColor: "rgba(8,12,24,0.75)",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider sx={{ my: 2, borderColor: "rgba(148,163,184,0.2)" }} />
|
||||
<Stack spacing={1.5}>
|
||||
{vpnPortGroups.map((group) => (
|
||||
<Box
|
||||
key={group.key}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: { xs: "flex-start", md: "center" },
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
background: "rgba(6,10,20,0.7)",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ color: MAGIC_UI.textBright, fontWeight: 600 }}>
|
||||
{group.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted, mt: 0.35 }}>
|
||||
{group.description}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.75} sx={{ mt: 0.8, flexWrap: "wrap" }}>
|
||||
{group.ports.map((port) => (
|
||||
<Chip
|
||||
key={`${group.key}-${port}`}
|
||||
label={`TCP ${port}`}
|
||||
size="small"
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
backgroundColor: "rgba(15,23,42,0.65)",
|
||||
color: MAGIC_UI.textMuted,
|
||||
border: `1px solid rgba(148,163,184,0.25)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={Boolean(vpnToggles[group.key])}
|
||||
onChange={(event) => handleVpnToggle(group.key, event.target.checked)}
|
||||
color="info"
|
||||
disabled={vpnLoading || vpnSaving}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
{vpnCustomPorts.length ? (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||
Custom ports preserved: {vpnCustomPorts.join(", ")}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
{vpnError ? (
|
||||
<Typography variant="body2" sx={{ color: "#ff7b89", mt: 1 }}>
|
||||
{vpnError}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack direction="row" spacing={1.25} sx={{ mt: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!vpnAgentId || vpnSaving || vpnLoading}
|
||||
onClick={saveVpnConfig}
|
||||
sx={{
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
px: 2.4,
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Save Config
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!vpnDefaultPorts.length || vpnSaving || vpnLoading}
|
||||
onClick={resetVpnConfig}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
px: 2.4,
|
||||
color: MAGIC_UI.textBright,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
backgroundColor: "rgba(8,12,24,0.6)",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(12,18,35,0.8)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Reset Defaults
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const memoryRows = useMemo(
|
||||
() =>
|
||||
(details.memory || []).map((m, idx) => ({
|
||||
@@ -1618,6 +1924,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
renderNetworkTab,
|
||||
renderSoftware,
|
||||
renderHistory,
|
||||
renderAdvancedConfigTab,
|
||||
renderRemoteShellTab,
|
||||
];
|
||||
const tabContent = (topTabRenderers[tab] || renderDeviceSummaryTab)();
|
||||
@@ -1742,7 +2049,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
||||
>
|
||||
{TOP_TABS.map((tabDef) => (
|
||||
<Tab
|
||||
key={tabDef.label}
|
||||
key={tabDef.key || tabDef.label}
|
||||
label={tabDef.label}
|
||||
icon={<tabDef.icon sx={{ fontSize: 18 }} />}
|
||||
iconPosition="start"
|
||||
|
||||
@@ -5,17 +5,17 @@ import {
|
||||
Button,
|
||||
Stack,
|
||||
TextField,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
PlayArrowRounded as PlayIcon,
|
||||
StopRounded as StopIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
RefreshRounded as RefreshIcon,
|
||||
LanRounded as PortIcon,
|
||||
LanRounded as IpIcon,
|
||||
LinkRounded as LinkIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { io } from "socket.io-client";
|
||||
@@ -24,18 +24,7 @@ import "prismjs/components/prism-powershell";
|
||||
import "prismjs/themes/prism-okaidia.css";
|
||||
import Editor from "react-simple-code-editor";
|
||||
|
||||
// Console diagnostics for troubleshooting the connect/disconnect flow.
|
||||
const debugLog = (...args) => {
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[ReverseTunnel][PS]", ...args);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const MAGIC_UI = {
|
||||
panelBg: "rgba(7,11,24,0.92)",
|
||||
panelBorder: "rgba(148, 163, 184, 0.35)",
|
||||
textMuted: "#94a3b8",
|
||||
textBright: "#e2e8f0",
|
||||
@@ -56,13 +45,25 @@ const gradientButtonSx = {
|
||||
},
|
||||
};
|
||||
|
||||
const FRAME_HEADER_BYTES = 12; // version, msg_type, flags, reserved, channel_id(u32), length(u32)
|
||||
const MSG_CLOSE = 0x08;
|
||||
const CLOSE_AGENT_SHUTDOWN = 6;
|
||||
|
||||
const fontFamilyMono =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
|
||||
const emitAsync = (socket, event, payload, timeoutMs = 4000) =>
|
||||
new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ error: "timeout" });
|
||||
}, timeoutMs);
|
||||
socket.emit(event, payload, (resp) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(resp || {});
|
||||
});
|
||||
});
|
||||
|
||||
function normalizeText(value) {
|
||||
if (value == null) return "";
|
||||
try {
|
||||
@@ -72,28 +73,6 @@ function normalizeText(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function base64FromBytes(bytes) {
|
||||
let binary = "";
|
||||
bytes.forEach((b) => {
|
||||
binary += String.fromCharCode(b);
|
||||
});
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function buildCloseFrame(channelId = 1, code = CLOSE_AGENT_SHUTDOWN, reason = "operator_close") {
|
||||
const payload = new TextEncoder().encode(JSON.stringify({ code, reason }));
|
||||
const buffer = new ArrayBuffer(FRAME_HEADER_BYTES + payload.length);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, 1); // version
|
||||
view.setUint8(1, MSG_CLOSE);
|
||||
view.setUint8(2, 0); // flags
|
||||
view.setUint8(3, 0); // reserved
|
||||
view.setUint32(4, channelId >>> 0, true);
|
||||
view.setUint32(8, payload.length >>> 0, true);
|
||||
new Uint8Array(buffer, FRAME_HEADER_BYTES).set(payload);
|
||||
return base64FromBytes(new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
function highlightPs(code) {
|
||||
try {
|
||||
return Prism.highlight(code || "", Prism.languages.powershell, "powershell");
|
||||
@@ -102,52 +81,18 @@ function highlightPs(code) {
|
||||
}
|
||||
}
|
||||
|
||||
const INITIAL_MILESTONES = {
|
||||
tunnelReady: false,
|
||||
operatorAttached: false,
|
||||
shellEstablished: false,
|
||||
};
|
||||
const INITIAL_STATUS_CHAIN = ["Offline"];
|
||||
|
||||
export default function ReverseTunnelPowershell({ device }) {
|
||||
const [connectionType, setConnectionType] = useState("ps");
|
||||
const [tunnel, setTunnel] = useState(null);
|
||||
const [sessionState, setSessionState] = useState("idle");
|
||||
const [, setStatusMessage] = useState("");
|
||||
const [, setStatusSeverity] = useState("info");
|
||||
const [shellState, setShellState] = useState("idle");
|
||||
const [tunnel, setTunnel] = useState(null);
|
||||
const [output, setOutput] = useState("");
|
||||
const [input, setInput] = useState("");
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const [copyFlash, setCopyFlash] = useState(false);
|
||||
const [, setPolling] = useState(false);
|
||||
const [psStatus, setPsStatus] = useState({});
|
||||
const [milestones, setMilestones] = useState(() => ({ ...INITIAL_MILESTONES }));
|
||||
const [tunnelSteps, setTunnelSteps] = useState(() => [...INITIAL_STATUS_CHAIN]);
|
||||
const [websocketSteps, setWebsocketSteps] = useState(() => [...INITIAL_STATUS_CHAIN]);
|
||||
const [shellSteps, setShellSteps] = useState(() => [...INITIAL_STATUS_CHAIN]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const socketRef = useRef(null);
|
||||
const pollTimerRef = useRef(null);
|
||||
const resizeTimerRef = useRef(null);
|
||||
const localSocketRef = useRef(false);
|
||||
const terminalRef = useRef(null);
|
||||
const joinRetryRef = useRef(null);
|
||||
const joinAttemptsRef = useRef(0);
|
||||
const tunnelRef = useRef(null);
|
||||
const shellFlagsRef = useRef({ openSent: false, ack: false });
|
||||
const DOMAIN_REMOTE_SHELL = "remote-interactive-shell";
|
||||
|
||||
useEffect(() => {
|
||||
debugLog("component mount", { hostname: device?.hostname, agentId });
|
||||
return () => debugLog("component unmount");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const hostname = useMemo(() => {
|
||||
return (
|
||||
normalizeText(device?.hostname) ||
|
||||
normalizeText(device?.summary?.hostname) ||
|
||||
normalizeText(device?.agent_hostname) ||
|
||||
""
|
||||
);
|
||||
}, [device]);
|
||||
|
||||
const agentId = useMemo(() => {
|
||||
return (
|
||||
@@ -162,78 +107,18 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
);
|
||||
}, [device]);
|
||||
|
||||
const appendStatus = useCallback((setter, label) => {
|
||||
if (!label) return;
|
||||
setter((prev) => {
|
||||
const next = [...prev, label];
|
||||
const cap = 6;
|
||||
return next.length > cap ? next.slice(next.length - cap) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
debugLog("resetState invoked");
|
||||
setTunnel(null);
|
||||
setSessionState("idle");
|
||||
setStatusMessage("");
|
||||
setStatusSeverity("info");
|
||||
setOutput("");
|
||||
setInput("");
|
||||
setPsStatus({});
|
||||
setMilestones({ ...INITIAL_MILESTONES });
|
||||
setTunnelSteps([...INITIAL_STATUS_CHAIN]);
|
||||
setWebsocketSteps([...INITIAL_STATUS_CHAIN]);
|
||||
setShellSteps([...INITIAL_STATUS_CHAIN]);
|
||||
shellFlagsRef.current = { openSent: false, ack: false };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
tunnelRef.current = tunnel?.tunnel_id || null;
|
||||
}, [tunnel?.tunnel_id]);
|
||||
|
||||
const disconnectSocket = useCallback(() => {
|
||||
const socket = socketRef.current;
|
||||
if (socket) {
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
const ensureSocket = useCallback(() => {
|
||||
if (socketRef.current) return socketRef.current;
|
||||
const existing = typeof window !== "undefined" ? window.BorealisSocket : null;
|
||||
if (existing) {
|
||||
socketRef.current = existing;
|
||||
localSocketRef.current = false;
|
||||
return existing;
|
||||
}
|
||||
socketRef.current = null;
|
||||
}, []);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
setPolling(false);
|
||||
}, []);
|
||||
|
||||
const stopTunnel = useCallback(async (reason = "operator_disconnect", tunnelIdOverride = null) => {
|
||||
const tunnelId = tunnelIdOverride || tunnelRef.current;
|
||||
if (!tunnelId) return;
|
||||
try {
|
||||
await fetch(`/api/tunnel/${tunnelId}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
} catch (err) {
|
||||
// best-effort; socket close frame acts as fallback
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debugLog("cleanup on unmount", { tunnelId: tunnelRef.current });
|
||||
stopPolling();
|
||||
disconnectSocket();
|
||||
if (joinRetryRef.current) {
|
||||
clearTimeout(joinRetryRef.current);
|
||||
joinRetryRef.current = null;
|
||||
}
|
||||
stopTunnel("component_unmount", tunnelRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const socket = io(window.location.origin, { transports: ["websocket"] });
|
||||
socketRef.current = socket;
|
||||
localSocketRef.current = true;
|
||||
return socket;
|
||||
}, []);
|
||||
|
||||
const appendOutput = useCallback((text) => {
|
||||
@@ -257,6 +142,137 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
scrollToBottom();
|
||||
}, [output, scrollToBottom]);
|
||||
|
||||
const stopTunnel = useCallback(
|
||||
async (reason = "operator_disconnect") => {
|
||||
if (!agentId) return;
|
||||
try {
|
||||
await fetch("/api/tunnel/disconnect", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_id: agentId, tunnel_id: tunnel?.tunnel_id, reason }),
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
[agentId, tunnel?.tunnel_id]
|
||||
);
|
||||
|
||||
const closeShell = useCallback(async () => {
|
||||
const socket = ensureSocket();
|
||||
await emitAsync(socket, "vpn_shell_close", {});
|
||||
}, [ensureSocket]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setStatusMessage("");
|
||||
try {
|
||||
await closeShell();
|
||||
await stopTunnel("operator_disconnect");
|
||||
} finally {
|
||||
setTunnel(null);
|
||||
setShellState("closed");
|
||||
setSessionState("idle");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [closeShell, stopTunnel]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = ensureSocket();
|
||||
const handleDisconnectEvent = () => {
|
||||
if (sessionState === "connected") {
|
||||
setShellState("closed");
|
||||
setSessionState("idle");
|
||||
setStatusMessage("Socket disconnected.");
|
||||
}
|
||||
};
|
||||
const handleOutput = (payload) => {
|
||||
appendOutput(payload?.data || "");
|
||||
};
|
||||
const handleClosed = () => {
|
||||
setShellState("closed");
|
||||
setSessionState("idle");
|
||||
setStatusMessage("Shell closed.");
|
||||
};
|
||||
|
||||
socket.on("disconnect", handleDisconnectEvent);
|
||||
socket.on("vpn_shell_output", handleOutput);
|
||||
socket.on("vpn_shell_closed", handleClosed);
|
||||
|
||||
return () => {
|
||||
socket.off("disconnect", handleDisconnectEvent);
|
||||
socket.off("vpn_shell_output", handleOutput);
|
||||
socket.off("vpn_shell_closed", handleClosed);
|
||||
if (localSocketRef.current) {
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
}, [appendOutput, ensureSocket, sessionState]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeShell();
|
||||
stopTunnel("component_unmount");
|
||||
};
|
||||
}, [closeShell, stopTunnel]);
|
||||
|
||||
const requestTunnel = useCallback(async () => {
|
||||
if (!agentId) {
|
||||
setStatusMessage("Agent ID is required to connect.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatusMessage("");
|
||||
setSessionState("connecting");
|
||||
setShellState("opening");
|
||||
try {
|
||||
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) throw new Error(data?.error || `HTTP ${resp.status}`);
|
||||
const statusResp = await fetch(
|
||||
`/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1`
|
||||
);
|
||||
const statusData = await statusResp.json().catch(() => ({}));
|
||||
if (!statusResp.ok || statusData?.status !== "up") {
|
||||
throw new Error(statusData?.error || "Tunnel not ready");
|
||||
}
|
||||
setTunnel({ ...data, ...statusData });
|
||||
|
||||
const socket = ensureSocket();
|
||||
const openResp = await emitAsync(socket, "vpn_shell_open", { agent_id: agentId }, 6000);
|
||||
if (openResp?.error) {
|
||||
throw new Error(openResp.error);
|
||||
}
|
||||
setSessionState("connected");
|
||||
setShellState("connected");
|
||||
} catch (err) {
|
||||
setSessionState("error");
|
||||
setShellState("closed");
|
||||
setStatusMessage(String(err.message || err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agentId, ensureSocket]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text) => {
|
||||
const socket = ensureSocket();
|
||||
if (!socket || sessionState !== "connected") return;
|
||||
const payload = `${text}${text.endsWith("\n") ? "" : "\r\n"}`;
|
||||
appendOutput(`\nPS> ${text}\n`);
|
||||
setInput("");
|
||||
const resp = await emitAsync(socket, "vpn_shell_send", { data: payload });
|
||||
if (resp?.error) {
|
||||
setStatusMessage("Send failed.");
|
||||
}
|
||||
},
|
||||
[appendOutput, ensureSocket, sessionState]
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(output || "");
|
||||
@@ -267,329 +283,7 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
}
|
||||
};
|
||||
|
||||
const measureTerminal = useCallback(() => {
|
||||
const el = terminalRef.current;
|
||||
if (!el) return { cols: 120, rows: 32 };
|
||||
const width = el.clientWidth || 960;
|
||||
const height = el.clientHeight || 460;
|
||||
const charWidth = 8.2;
|
||||
const charHeight = 18;
|
||||
const cols = Math.max(20, Math.min(Math.floor(width / charWidth), 300));
|
||||
const rows = Math.max(10, Math.min(Math.floor(height / charHeight), 200));
|
||||
return { cols, rows };
|
||||
}, []);
|
||||
|
||||
const emitAsync = useCallback((socket, event, payload, timeoutMs = 4000) => {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ error: "timeout" });
|
||||
}, timeoutMs);
|
||||
socket.emit(event, payload, (resp) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(resp || {});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pollLoop = useCallback(
|
||||
(socket, tunnelId) => {
|
||||
if (!socket || !tunnelId) return;
|
||||
debugLog("pollLoop tick", { tunnelId });
|
||||
setPolling(true);
|
||||
pollTimerRef.current = setTimeout(async () => {
|
||||
const resp = await emitAsync(socket, "ps_poll", {});
|
||||
if (resp?.error) {
|
||||
debugLog("pollLoop error", resp);
|
||||
stopPolling();
|
||||
disconnectSocket();
|
||||
setPsStatus({});
|
||||
setTunnel(null);
|
||||
setSessionState("error");
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(resp?.output) && resp.output.length) {
|
||||
appendOutput(resp.output.join(""));
|
||||
}
|
||||
if (resp?.status) {
|
||||
setPsStatus(resp.status);
|
||||
debugLog("pollLoop status", resp.status);
|
||||
if (resp.status.closed) {
|
||||
setSessionState("closed");
|
||||
setTunnel(null);
|
||||
setMilestones({ ...INITIAL_MILESTONES });
|
||||
appendStatus(setShellSteps, "Shell closed");
|
||||
appendStatus(setTunnelSteps, "Stopped");
|
||||
appendStatus(setWebsocketSteps, "Relay stopped");
|
||||
shellFlagsRef.current = { openSent: false, ack: false };
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (resp.status.open_sent && !shellFlagsRef.current.openSent) {
|
||||
appendStatus(setShellSteps, "Opening remote shell");
|
||||
shellFlagsRef.current.openSent = true;
|
||||
}
|
||||
if (resp.status.ack && !shellFlagsRef.current.ack) {
|
||||
setSessionState("connected");
|
||||
setMilestones((prev) => ({ ...prev, shellEstablished: true }));
|
||||
appendStatus(setShellSteps, "Remote shell established");
|
||||
shellFlagsRef.current.ack = true;
|
||||
}
|
||||
}
|
||||
pollLoop(socket, tunnelId);
|
||||
}, 520);
|
||||
},
|
||||
[appendOutput, emitAsync, stopPolling, disconnectSocket, appendStatus]
|
||||
);
|
||||
|
||||
const handleDisconnect = useCallback(
|
||||
async (reason = "operator_disconnect") => {
|
||||
debugLog("handleDisconnect begin", { reason, tunnelId: tunnel?.tunnel_id, psStatus, sessionState });
|
||||
setPsStatus({});
|
||||
const socket = socketRef.current;
|
||||
const tunnelId = tunnel?.tunnel_id;
|
||||
if (joinRetryRef.current) {
|
||||
clearTimeout(joinRetryRef.current);
|
||||
joinRetryRef.current = null;
|
||||
}
|
||||
joinAttemptsRef.current = 0;
|
||||
if (socket && tunnelId) {
|
||||
const frame = buildCloseFrame(1, CLOSE_AGENT_SHUTDOWN, "operator_close");
|
||||
debugLog("emit CLOSE", { tunnelId });
|
||||
socket.emit("send", { frame });
|
||||
}
|
||||
await stopTunnel(reason);
|
||||
debugLog("stopTunnel issued", { tunnelId });
|
||||
stopPolling();
|
||||
disconnectSocket();
|
||||
setTunnel(null);
|
||||
setSessionState("closed");
|
||||
setMilestones({ ...INITIAL_MILESTONES });
|
||||
appendStatus(setTunnelSteps, "Stopped");
|
||||
appendStatus(setWebsocketSteps, "Relay closed");
|
||||
appendStatus(setShellSteps, "Shell closed");
|
||||
shellFlagsRef.current = { openSent: false, ack: false };
|
||||
debugLog("handleDisconnect finished", { tunnelId });
|
||||
},
|
||||
[appendStatus, disconnectSocket, stopPolling, stopTunnel, tunnel?.tunnel_id]
|
||||
);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (!socketRef.current || sessionState === "idle") return;
|
||||
const dims = measureTerminal();
|
||||
socketRef.current.emit("ps_resize", dims);
|
||||
}, [measureTerminal, sessionState]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer =
|
||||
typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => {
|
||||
if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);
|
||||
resizeTimerRef.current = setTimeout(() => handleResize(), 200);
|
||||
})
|
||||
: null;
|
||||
const el = terminalRef.current;
|
||||
if (observer && el) observer.observe(el);
|
||||
const onWinResize = () => handleResize();
|
||||
window.addEventListener("resize", onWinResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onWinResize);
|
||||
if (observer && el) observer.unobserve(el);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
const connectSocket = useCallback(
|
||||
(lease, { isRetry = false } = {}) => {
|
||||
if (!lease?.tunnel_id) return;
|
||||
if (joinRetryRef.current) {
|
||||
clearTimeout(joinRetryRef.current);
|
||||
joinRetryRef.current = null;
|
||||
}
|
||||
if (!isRetry) {
|
||||
joinAttemptsRef.current = 0;
|
||||
}
|
||||
disconnectSocket();
|
||||
stopPolling();
|
||||
setSessionState("waiting");
|
||||
const socket = io(`${window.location.origin}/tunnel`, { transports: ["websocket", "polling"] });
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect_error", () => {
|
||||
debugLog("socket connect_error");
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Tunnel namespace unavailable.");
|
||||
setTunnel(null);
|
||||
setSessionState("error");
|
||||
appendStatus(setWebsocketSteps, "Relay connect error");
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
debugLog("socket disconnect", { tunnelId: tunnel?.tunnel_id });
|
||||
stopPolling();
|
||||
if (sessionState !== "closed") {
|
||||
setSessionState("disconnected");
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Socket disconnected.");
|
||||
setTunnel(null);
|
||||
appendStatus(setWebsocketSteps, "Relay disconnected");
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("connect", async () => {
|
||||
debugLog("socket connect", { tunnelId: lease.tunnel_id });
|
||||
setMilestones((prev) => ({ ...prev, operatorAttached: true }));
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("Joining tunnel...");
|
||||
appendStatus(setWebsocketSteps, "Relay connected");
|
||||
const joinResp = await emitAsync(socket, "join", { tunnel_id: lease.tunnel_id }, 5000);
|
||||
if (joinResp?.error) {
|
||||
const attempt = (joinAttemptsRef.current += 1);
|
||||
const isTimeout = joinResp.error === "timeout";
|
||||
if (joinResp.error === "unknown_tunnel") {
|
||||
setSessionState("waiting_agent");
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("Waiting for agent to establish tunnel...");
|
||||
appendStatus(setWebsocketSteps, "Waiting for agent");
|
||||
} else if (isTimeout || joinResp.error === "attach_failed") {
|
||||
setSessionState("waiting_agent");
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Tunnel join timed out. Retrying...");
|
||||
appendStatus(setWebsocketSteps, `Join retry ${attempt}`);
|
||||
} else {
|
||||
debugLog("join error", joinResp);
|
||||
setSessionState("error");
|
||||
setStatusSeverity("error");
|
||||
setStatusMessage(joinResp.error);
|
||||
appendStatus(setWebsocketSteps, `Join failed: ${joinResp.error}`);
|
||||
return;
|
||||
}
|
||||
if (attempt <= 5) {
|
||||
joinRetryRef.current = setTimeout(() => connectSocket(lease, { isRetry: true }), 800);
|
||||
} else {
|
||||
setSessionState("error");
|
||||
setTunnel(null);
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Operator could not attach to tunnel. Try Connect again.");
|
||||
appendStatus(setWebsocketSteps, "Join failed after retries");
|
||||
}
|
||||
return;
|
||||
}
|
||||
appendStatus(setWebsocketSteps, "Relay joined");
|
||||
const dims = measureTerminal();
|
||||
debugLog("ps_open emit", { tunnelId: lease.tunnel_id, dims });
|
||||
const openResp = await emitAsync(socket, "ps_open", dims, 5000);
|
||||
if (openResp?.error && openResp.error === "ps_unsupported") {
|
||||
// Suppress warming message; channel will settle once agent attaches.
|
||||
}
|
||||
if (!shellFlagsRef.current.openSent) {
|
||||
appendStatus(setShellSteps, "Opening remote shell");
|
||||
shellFlagsRef.current.openSent = true;
|
||||
}
|
||||
appendOutput("");
|
||||
setSessionState("waiting_agent");
|
||||
pollLoop(socket, lease.tunnel_id);
|
||||
handleResize();
|
||||
});
|
||||
},
|
||||
[appendOutput, appendStatus, disconnectSocket, emitAsync, handleResize, measureTerminal, pollLoop, sessionState, stopPolling]
|
||||
);
|
||||
|
||||
const requestTunnel = useCallback(async () => {
|
||||
if (tunnel && sessionState !== "closed" && sessionState !== "idle") {
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("");
|
||||
connectSocket(tunnel);
|
||||
return;
|
||||
}
|
||||
debugLog("requestTunnel", { agentId, connectionType });
|
||||
if (!agentId) {
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Agent ID is required to request a tunnel.");
|
||||
return;
|
||||
}
|
||||
if (connectionType !== "ps") {
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("Only PowerShell is supported right now.");
|
||||
return;
|
||||
}
|
||||
resetState();
|
||||
setSessionState("requesting");
|
||||
setStatusSeverity("info");
|
||||
setStatusMessage("");
|
||||
appendStatus(setTunnelSteps, "Requesting lease");
|
||||
try {
|
||||
const resp = await fetch("/api/tunnel/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_id: agentId, protocol: "ps", domain: DOMAIN_REMOTE_SHELL }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = data?.error || `HTTP ${resp.status}`;
|
||||
setSessionState("error");
|
||||
setStatusSeverity(err === "domain_limit" ? "warning" : "error");
|
||||
setStatusMessage("");
|
||||
return;
|
||||
}
|
||||
setMilestones((prev) => ({ ...prev, tunnelReady: true }));
|
||||
setTunnel(data);
|
||||
setStatusMessage("");
|
||||
setSessionState("lease_issued");
|
||||
appendStatus(
|
||||
setTunnelSteps,
|
||||
data?.tunnel_id
|
||||
? `Lease issued (${data.tunnel_id.slice(0, 8)} @ Port ${data.port || "-"})`
|
||||
: "Lease issued"
|
||||
);
|
||||
connectSocket(data);
|
||||
} catch (e) {
|
||||
setSessionState("error");
|
||||
setStatusSeverity("error");
|
||||
setStatusMessage("");
|
||||
appendStatus(setTunnelSteps, "Lease request failed");
|
||||
}
|
||||
}, [DOMAIN_REMOTE_SHELL, agentId, appendStatus, connectSocket, connectionType, resetState]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text) => {
|
||||
const socket = socketRef.current;
|
||||
if (!socket) return;
|
||||
const payload = `${text}${text.endsWith("\n") ? "" : "\r\n"}`;
|
||||
appendOutput(`\nPS> ${text}\n`);
|
||||
setInput("");
|
||||
const resp = await emitAsync(socket, "ps_send", { data: payload });
|
||||
if (resp?.error) {
|
||||
setStatusSeverity("warning");
|
||||
setStatusMessage("");
|
||||
}
|
||||
},
|
||||
[appendOutput, emitAsync]
|
||||
);
|
||||
|
||||
const isConnected = sessionState === "connected" || (psStatus?.ack && !psStatus?.closed);
|
||||
const isClosed = sessionState === "closed" || psStatus?.closed;
|
||||
const isBusy =
|
||||
sessionState === "requesting" ||
|
||||
sessionState === "waiting" ||
|
||||
sessionState === "waiting_agent" ||
|
||||
sessionState === "lease_issued";
|
||||
const canStart = Boolean(agentId) && !isBusy;
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnload = () => {
|
||||
stopTunnel("window_unload");
|
||||
};
|
||||
if (tunnel?.tunnel_id) {
|
||||
window.addEventListener("beforeunload", handleUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleUnload);
|
||||
}
|
||||
return undefined;
|
||||
}, [stopTunnel, tunnel?.tunnel_id]);
|
||||
|
||||
const isConnected = sessionState === "connected";
|
||||
const sessionChips = [
|
||||
tunnel?.tunnel_id
|
||||
? {
|
||||
@@ -598,58 +292,43 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
icon: <LinkIcon sx={{ fontSize: 18 }} />,
|
||||
}
|
||||
: null,
|
||||
tunnel?.port
|
||||
tunnel?.virtual_ip
|
||||
? {
|
||||
label: `Port ${tunnel.port}`,
|
||||
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
|
||||
color: MAGIC_UI.accentA,
|
||||
icon: <PortIcon sx={{ fontSize: 18 }} />,
|
||||
icon: <IpIcon sx={{ fontSize: 18 }} />,
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
|
||||
<Box>
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={1.5}
|
||||
alignItems={{ xs: "flex-start", sm: "center" }}
|
||||
justifyContent={{ xs: "flex-start", sm: "flex-end" }}
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", sm: "center" }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
|
||||
sx={gradientButtonSx}
|
||||
disabled={loading || (!isConnected && !agentId)}
|
||||
onClick={isConnected ? handleDisconnect : requestTunnel}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
label="Connection Protocol"
|
||||
size="small"
|
||||
value={connectionType}
|
||||
onChange={(e) => setConnectionType(e.target.value)}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
"& .MuiInputBase-root": {
|
||||
backgroundColor: "rgba(12,18,35,0.85)",
|
||||
color: MAGIC_UI.textBright,
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
"& fieldset": { borderColor: MAGIC_UI.panelBorder },
|
||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
||||
}}
|
||||
>
|
||||
<MenuItem value="ps">PowerShell</MenuItem>
|
||||
</TextField>
|
||||
<Tooltip title={isConnected ? "Disconnect session" : "Connect to agent"}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
|
||||
sx={gradientButtonSx}
|
||||
disabled={!isConnected && !canStart}
|
||||
onClick={isConnected ? handleDisconnect : requestTunnel}
|
||||
>
|
||||
{isConnected ? "Disconnect" : "Connect"}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{isConnected ? "Disconnect" : "Connect"}
|
||||
</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>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
@@ -665,7 +344,7 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{isBusy ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
|
||||
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
|
||||
<Box
|
||||
ref={terminalRef}
|
||||
sx={{
|
||||
@@ -728,11 +407,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" : "Connect to start sending commands"}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -753,43 +428,19 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Stack spacing={0.3} sx={{ mt: 1.25 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: milestones.tunnelReady ? MAGIC_UI.accentC : MAGIC_UI.textMuted,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Tunnel:{" "}
|
||||
<Typography component="span" variant="body2" sx={{ color: MAGIC_UI.textMuted, fontWeight: 500 }}>
|
||||
{tunnelSteps.join(" > ")}
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={0.3} sx={{ mt: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||
Tunnel: {sessionState === "connected" ? "Active" : sessionState}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: milestones.operatorAttached ? MAGIC_UI.accentC : MAGIC_UI.textMuted,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Websocket:{" "}
|
||||
<Typography component="span" variant="body2" sx={{ color: MAGIC_UI.textMuted, fontWeight: 500 }}>
|
||||
{websocketSteps.join(" > ")}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||
Shell: {shellState === "connected" ? "Ready" : shellState}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: milestones.shellEstablished ? MAGIC_UI.accentC : MAGIC_UI.textMuted,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Remote Shell:{" "}
|
||||
<Typography component="span" variant="body2" sx={{ color: MAGIC_UI.textMuted, fontWeight: 500 }}>
|
||||
{shellSteps.join(" > ")}
|
||||
{statusMessage ? (
|
||||
<Typography variant="body2" sx={{ color: "#ff7b89" }}>
|
||||
{statusMessage}
|
||||
</Typography>
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user