import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Typography, Button, Stack, TextField, MenuItem, IconButton, Tooltip, LinearProgress, } from "@mui/material"; import { PlayArrowRounded as PlayIcon, StopRounded as StopIcon, ContentCopy as CopyIcon, RefreshRounded as RefreshIcon, LanRounded as PortIcon, LinkRounded as LinkIcon, } from "@mui/icons-material"; import { io } from "socket.io-client"; import Prism from "prismjs"; 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", 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 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'; function normalizeText(value) { if (value == null) return ""; try { return String(value).trim(); } catch { return ""; } } 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"); } catch { return 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 [output, setOutput] = useState(""); const [input, setInput] = 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 socketRef = useRef(null); const pollTimerRef = useRef(null); const resizeTimerRef = useRef(null); 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 ( 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]); 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(); } 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 appendOutput = useCallback((text) => { if (!text) return; setOutput((prev) => { const next = `${prev}${text}`; const limit = 40000; return next.length > limit ? next.slice(next.length - limit) : next; }); }, []); const scrollToBottom = useCallback(() => { const el = terminalRef.current; if (!el) return; requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }); }, []); useEffect(() => { scrollToBottom(); }, [output, scrollToBottom]); const handleCopy = async () => { try { await navigator.clipboard.writeText(output || ""); setCopyFlash(true); setTimeout(() => setCopyFlash(false), 1200); } catch { setCopyFlash(false); } }; 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 sessionChips = [ tunnel?.tunnel_id ? { label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`, color: MAGIC_UI.accentB, icon: , } : null, tunnel?.port ? { label: `Port ${tunnel.port}`, color: MAGIC_UI.accentA, icon: , } : null, ].filter(Boolean); return ( 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 }, }} > PowerShell {isBusy ? : null} {}} highlight={highlightPs} padding={12} readOnly style={{ minHeight: "100%", background: "transparent", color: "#e6edf3", fontFamily: fontFamilyMono, fontSize: 13, }} /> setOutput("")} sx={{ color: MAGIC_UI.textMuted }} > setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); const text = input.trim(); if (text) handleSend(text); } }} InputProps={{ sx: { backgroundColor: "rgba(12,18,35,0.9)", color: "#e2e8f0", borderRadius: 2, "& fieldset": { borderColor: "rgba(148,163,184,0.45)" }, "&:hover fieldset": { borderColor: MAGIC_UI.accentA }, }, }} /> Tunnel:{" "} {tunnelSteps.join(" > ")} Websocket:{" "} {websocketSteps.join(" > ")} Remote Shell:{" "} {shellSteps.join(" > ")} ); }