Adjusted Remote Shell Behavior to be more Deterministic

This commit is contained in:
2025-12-07 06:08:59 -07:00
parent 15ec81d750
commit 8bbd6b86ed

View File

@@ -118,6 +118,7 @@ export default function ReverseTunnelPowershell({ device }) {
const [copyFlash, setCopyFlash] = useState(false); const [copyFlash, setCopyFlash] = useState(false);
const [, setPolling] = useState(false); const [, setPolling] = useState(false);
const [psStatus, setPsStatus] = useState({}); const [psStatus, setPsStatus] = useState({});
const [statusLine, setStatusLine] = useState("Idle");
const [milestones, setMilestones] = useState({ const [milestones, setMilestones] = useState({
requested: false, requested: false,
leaseIssued: false, leaseIssued: false,
@@ -132,6 +133,7 @@ export default function ReverseTunnelPowershell({ device }) {
const terminalRef = useRef(null); const terminalRef = useRef(null);
const joinRetryRef = useRef(null); const joinRetryRef = useRef(null);
const joinAttemptsRef = useRef(0); const joinAttemptsRef = useRef(0);
const tunnelRef = useRef(null);
const DOMAIN_REMOTE_SHELL = "remote-interactive-shell"; const DOMAIN_REMOTE_SHELL = "remote-interactive-shell";
useEffect(() => { useEffect(() => {
@@ -185,8 +187,13 @@ export default function ReverseTunnelPowershell({ device }) {
ack: false, ack: false,
active: false, active: false,
}); });
setStatusLine("Idle");
}, []); }, []);
useEffect(() => {
tunnelRef.current = tunnel?.tunnel_id || null;
}, [tunnel?.tunnel_id]);
const disconnectSocket = useCallback(() => { const disconnectSocket = useCallback(() => {
const socket = socketRef.current; const socket = socketRef.current;
if (socket) { if (socket) {
@@ -197,6 +204,7 @@ export default function ReverseTunnelPowershell({ device }) {
}, []); }, []);
const stopPolling = useCallback(() => { const stopPolling = useCallback(() => {
setStatusLine("Stopping poll loop…");
if (pollTimerRef.current) { if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current); clearTimeout(pollTimerRef.current);
pollTimerRef.current = null; pollTimerRef.current = null;
@@ -204,35 +212,34 @@ export default function ReverseTunnelPowershell({ device }) {
setPolling(false); setPolling(false);
}, []); }, []);
const stopTunnel = useCallback( const stopTunnel = useCallback(async (reason = "operator_disconnect", tunnelIdOverride = null) => {
async (reason = "operator_disconnect") => { const tunnelId = tunnelIdOverride || tunnelRef.current;
const tunnelId = tunnel?.tunnel_id; if (!tunnelId) return;
if (!tunnelId) return; setStatusLine(`Stopping tunnel ${tunnelId} reason=${reason}`);
try { try {
await fetch(`/api/tunnel/${tunnelId}`, { await fetch(`/api/tunnel/${tunnelId}`, {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }), body: JSON.stringify({ reason }),
}); });
} catch (err) { } catch (err) {
// best-effort; socket close frame acts as fallback // best-effort; socket close frame acts as fallback
} }
}, }, []);
[tunnel?.tunnel_id]
);
useEffect(() => { useEffect(() => {
return () => { return () => {
debugLog("cleanup on unmount", { tunnelId: tunnel?.tunnel_id }); debugLog("cleanup on unmount", { tunnelId: tunnelRef.current });
stopPolling(); stopPolling();
disconnectSocket(); disconnectSocket();
if (joinRetryRef.current) { if (joinRetryRef.current) {
clearTimeout(joinRetryRef.current); clearTimeout(joinRetryRef.current);
joinRetryRef.current = null; joinRetryRef.current = null;
} }
stopTunnel("component_unmount"); stopTunnel("component_unmount", tunnelRef.current);
}; };
}, [disconnectSocket, stopPolling, stopTunnel]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const appendOutput = useCallback((text) => { const appendOutput = useCallback((text) => {
if (!text) return; if (!text) return;
@@ -297,6 +304,7 @@ export default function ReverseTunnelPowershell({ device }) {
const pollLoop = useCallback( const pollLoop = useCallback(
(socket, tunnelId) => { (socket, tunnelId) => {
if (!socket || !tunnelId) return; if (!socket || !tunnelId) return;
setStatusLine(`Polling tunnel ${tunnelId}`);
debugLog("pollLoop tick", { tunnelId }); debugLog("pollLoop tick", { tunnelId });
setPolling(true); setPolling(true);
pollTimerRef.current = setTimeout(async () => { pollTimerRef.current = setTimeout(async () => {
@@ -317,6 +325,7 @@ export default function ReverseTunnelPowershell({ device }) {
setPsStatus(resp.status); setPsStatus(resp.status);
debugLog("pollLoop status", resp.status); debugLog("pollLoop status", resp.status);
if (resp.status.closed) { if (resp.status.closed) {
setStatusLine(`Tunnel ${tunnelId} reported closed`);
setSessionState("closed"); setSessionState("closed");
setTunnel(null); setTunnel(null);
stopPolling(); stopPolling();
@@ -324,10 +333,12 @@ export default function ReverseTunnelPowershell({ device }) {
} }
if (resp.status.open_sent) { if (resp.status.open_sent) {
setMilestones((prev) => ({ ...prev, channelOpened: true })); setMilestones((prev) => ({ ...prev, channelOpened: true }));
setStatusLine("Remote shell opening…");
} }
if (resp.status.ack) { if (resp.status.ack) {
setSessionState("connected"); setSessionState("connected");
setMilestones((prev) => ({ ...prev, ack: true, active: true })); setMilestones((prev) => ({ ...prev, ack: true, active: true }));
setStatusLine("Remote shell active");
} }
} }
pollLoop(socket, tunnelId); pollLoop(socket, tunnelId);
@@ -339,6 +350,7 @@ export default function ReverseTunnelPowershell({ device }) {
const handleDisconnect = useCallback( const handleDisconnect = useCallback(
async (reason = "operator_disconnect") => { async (reason = "operator_disconnect") => {
debugLog("handleDisconnect begin", { reason, tunnelId: tunnel?.tunnel_id, psStatus, sessionState }); debugLog("handleDisconnect begin", { reason, tunnelId: tunnel?.tunnel_id, psStatus, sessionState });
setStatusLine(`Disconnect requested (${reason})`);
const socket = socketRef.current; const socket = socketRef.current;
const tunnelId = tunnel?.tunnel_id; const tunnelId = tunnel?.tunnel_id;
if (joinRetryRef.current) { if (joinRetryRef.current) {
@@ -358,6 +370,7 @@ export default function ReverseTunnelPowershell({ device }) {
setTunnel(null); setTunnel(null);
setSessionState("closed"); setSessionState("closed");
setMilestones((prev) => ({ ...prev, active: false })); setMilestones((prev) => ({ ...prev, active: false }));
setStatusLine("Disconnected");
debugLog("handleDisconnect finished", { tunnelId }); debugLog("handleDisconnect finished", { tunnelId });
}, },
[disconnectSocket, stopPolling, stopTunnel, tunnel?.tunnel_id] [disconnectSocket, stopPolling, stopTunnel, tunnel?.tunnel_id]
@@ -400,10 +413,12 @@ export default function ReverseTunnelPowershell({ device }) {
disconnectSocket(); disconnectSocket();
stopPolling(); stopPolling();
setSessionState("waiting"); setSessionState("waiting");
const socket = io(`${window.location.origin}/tunnel`, { transports: ["websocket"] }); const socket = io(`${window.location.origin}/tunnel`, { transports: ["websocket", "polling"] });
socketRef.current = socket; socketRef.current = socket;
setStatusLine("Connecting to tunnel namespace…");
socket.on("connect_error", () => { socket.on("connect_error", () => {
setStatusLine("Socket connect error");
debugLog("socket connect_error"); debugLog("socket connect_error");
setStatusSeverity("warning"); setStatusSeverity("warning");
setStatusMessage("Tunnel namespace unavailable."); setStatusMessage("Tunnel namespace unavailable.");
@@ -412,6 +427,7 @@ export default function ReverseTunnelPowershell({ device }) {
}); });
socket.on("disconnect", () => { socket.on("disconnect", () => {
setStatusLine("Socket disconnected");
debugLog("socket disconnect", { tunnelId: tunnel?.tunnel_id }); debugLog("socket disconnect", { tunnelId: tunnel?.tunnel_id });
stopPolling(); stopPolling();
if (sessionState !== "closed") { if (sessionState !== "closed") {
@@ -425,6 +441,7 @@ export default function ReverseTunnelPowershell({ device }) {
socket.on("connect", async () => { socket.on("connect", async () => {
debugLog("socket connect", { tunnelId: lease.tunnel_id }); debugLog("socket connect", { tunnelId: lease.tunnel_id });
setMilestones((prev) => ({ ...prev, operatorJoined: true })); setMilestones((prev) => ({ ...prev, operatorJoined: true }));
setStatusLine("Operator attached; joining tunnel…");
setStatusSeverity("info"); setStatusSeverity("info");
setStatusMessage("Joining tunnel..."); setStatusMessage("Joining tunnel...");
const joinResp = await emitAsync(socket, "join", { tunnel_id: lease.tunnel_id }, 5000); const joinResp = await emitAsync(socket, "join", { tunnel_id: lease.tunnel_id }, 5000);
@@ -435,15 +452,18 @@ export default function ReverseTunnelPowershell({ device }) {
setSessionState("waiting_agent"); setSessionState("waiting_agent");
setStatusSeverity("info"); setStatusSeverity("info");
setStatusMessage("Waiting for agent to establish tunnel..."); setStatusMessage("Waiting for agent to establish tunnel...");
setStatusLine("Waiting for agent to attach to tunnel…");
} else if (isTimeout || joinResp.error === "attach_failed") { } else if (isTimeout || joinResp.error === "attach_failed") {
setSessionState("waiting_agent"); setSessionState("waiting_agent");
setStatusSeverity("warning"); setStatusSeverity("warning");
setStatusMessage("Tunnel join timed out. Retrying..."); setStatusMessage("Tunnel join timed out. Retrying...");
setStatusLine(`Join timed out (attempt ${attempt}/5); retrying…`);
} else { } else {
debugLog("join error", joinResp); debugLog("join error", joinResp);
setSessionState("error"); setSessionState("error");
setStatusSeverity("error"); setStatusSeverity("error");
setStatusMessage(joinResp.error); setStatusMessage(joinResp.error);
setStatusLine(`Join failed: ${joinResp.error}`);
return; return;
} }
if (attempt <= 5) { if (attempt <= 5) {
@@ -453,12 +473,14 @@ export default function ReverseTunnelPowershell({ device }) {
setTunnel(null); setTunnel(null);
setStatusSeverity("warning"); setStatusSeverity("warning");
setStatusMessage("Operator could not attach to tunnel. Try Connect again."); setStatusMessage("Operator could not attach to tunnel. Try Connect again.");
setStatusLine("Operator attach failed after retries");
} }
return; return;
} }
const dims = measureTerminal(); const dims = measureTerminal();
debugLog("ps_open emit", { tunnelId: lease.tunnel_id, dims }); debugLog("ps_open emit", { tunnelId: lease.tunnel_id, dims });
setMilestones((prev) => ({ ...prev, channelOpened: true })); setMilestones((prev) => ({ ...prev, channelOpened: true }));
setStatusLine("Opening remote shell…");
const openResp = await emitAsync(socket, "ps_open", dims, 5000); const openResp = await emitAsync(socket, "ps_open", dims, 5000);
if (openResp?.error && openResp.error === "ps_unsupported") { if (openResp?.error && openResp.error === "ps_unsupported") {
// Suppress warming message; channel will settle once agent attaches. // Suppress warming message; channel will settle once agent attaches.
@@ -480,6 +502,7 @@ export default function ReverseTunnelPowershell({ device }) {
return; return;
} }
debugLog("requestTunnel", { agentId, connectionType }); debugLog("requestTunnel", { agentId, connectionType });
setStatusLine("Requesting tunnel…");
if (!agentId) { if (!agentId) {
setStatusSeverity("warning"); setStatusSeverity("warning");
setStatusMessage("Agent ID is required to request a tunnel."); setStatusMessage("Agent ID is required to request a tunnel.");
@@ -512,11 +535,13 @@ export default function ReverseTunnelPowershell({ device }) {
setTunnel(data); setTunnel(data);
setStatusMessage(""); setStatusMessage("");
setSessionState("lease_issued"); setSessionState("lease_issued");
setStatusLine(`Lease issued for tunnel ${data.tunnel_id || "-"}`);
connectSocket(data); connectSocket(data);
} catch (e) { } catch (e) {
setSessionState("error"); setSessionState("error");
setStatusSeverity("error"); setStatusSeverity("error");
setStatusMessage(""); setStatusMessage("");
setStatusLine("Tunnel request failed");
} }
}, [DOMAIN_REMOTE_SHELL, agentId, connectSocket, connectionType, resetState]); }, [DOMAIN_REMOTE_SHELL, agentId, connectSocket, connectionType, resetState]);
@@ -674,6 +699,19 @@ export default function ReverseTunnelPowershell({ device }) {
); );
})} })}
</Box> </Box>
<Typography
variant="body2"
sx={{
mt: 0.5,
color: MAGIC_UI.textMuted,
fontWeight: 600,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{statusLine}
</Typography>
</Box> </Box>
<Box <Box