Additional Reverse Shell code cleanup

This commit is contained in:
2025-12-06 00:45:04 -07:00
parent 68dd46347b
commit cc8a1fade0
4 changed files with 26 additions and 155 deletions

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
import os
import sys
import subprocess
from typing import Any, Dict, Optional
@@ -30,7 +29,6 @@ class PowershellChannel:
self._reader_task = None
self._writer_task = None
self._stdin_queue: asyncio.Queue = asyncio.Queue()
self._pty = None
self._proc: Optional[asyncio.subprocess.Process] = None
self._exit_code: Optional[int] = None
self._frame_cls = getattr(role, "_frame_cls", None)
@@ -70,13 +68,6 @@ class PowershellChannel:
# Keep the process alive and read commands from stdin; -Command - tells PS to consume stdin.
return [shell, "-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"]
def _initial_size(self) -> tuple:
cols = int(self.metadata.get("cols") or self.metadata.get("columns") or 120) if isinstance(self.metadata, dict) else 120
rows = int(self.metadata.get("rows") or 32) if isinstance(self.metadata, dict) else 32
cols = max(20, min(cols, 300))
rows = max(10, min(rows, 200))
return cols, rows
# ------------------------------------------------------------------ Lifecycle
async def start(self) -> None:
if sys.platform.lower().startswith("win") is False:
@@ -85,25 +76,9 @@ class PowershellChannel:
return
argv = self._powershell_argv()
cols, rows = self._initial_size()
self.role._log(f"reverse_tunnel ps start channel={self.channel_id} argv={' '.join(argv)} cols={cols} rows={rows}")
self.role._log(f"reverse_tunnel ps start channel={self.channel_id} argv={' '.join(argv)} mode=pipes")
# Preferred: ConPTY via pywinpty.
try:
import pywinpty # type: ignore
self._pty = pywinpty.Process(
spawn_cmd=" ".join(argv[:-1]) if argv[-1] == "-" else " ".join(argv),
dimensions=(cols, rows),
)
self._reader_task = self.loop.create_task(self._pump_pty_stdout())
self._writer_task = self.loop.create_task(self._pump_pty_stdin())
self.role._log(f"reverse_tunnel ps channel started (pty) argv={' '.join(argv)} cols={cols} rows={rows}")
return
except Exception as exc:
self.role._log(f"reverse_tunnel ps channel pywinpty unavailable, falling back to pipes: {exc}", error=True)
# Fallback: subprocess pipes (no PTY).
# Pipes (no PTY).
try:
self._proc = await asyncio.create_subprocess_exec(
*argv,
@@ -119,7 +94,7 @@ class PowershellChannel:
self._reader_task = self.loop.create_task(self._pump_proc_stdout())
self._writer_task = self.loop.create_task(self._pump_proc_stdin())
self.role._log(f"reverse_tunnel ps channel started (pipes) argv={' '.join(argv)} cols={cols} rows={rows}")
self.role._log(f"reverse_tunnel ps channel started (pipes) argv={' '.join(argv)}")
async def on_frame(self, frame) -> None:
if self._closed:
@@ -139,80 +114,8 @@ class PowershellChannel:
return
async def _handle_control(self, payload: bytes) -> None:
try:
import json
data = json.loads(payload.decode("utf-8"))
except Exception:
return
cols = data.get("cols") or data.get("columns")
rows = data.get("rows")
if cols is None and rows is None:
return
try:
cols_int = int(cols) if cols is not None else None
rows_int = int(rows) if rows is not None else None
except Exception:
return
await self._resize(cols_int, rows_int)
async def _resize(self, cols: Optional[int], rows: Optional[int]) -> None:
# Resize only applies to PTY sessions; pipe mode ignores.
if self._pty is None:
return
try:
cur_cols, cur_rows = self._initial_size()
if cols is None:
cols = cur_cols
if rows is None:
rows = cur_rows
cols = max(20, min(int(cols), 300))
rows = max(10, min(int(rows), 200))
self._pty.set_size(cols, rows)
self.role._log(f"reverse_tunnel ps channel resized cols={cols} rows={rows}")
except Exception:
self.role._log("reverse_tunnel ps channel resize failed", error=True)
async def _pump_pty_stdout(self) -> None:
loop = asyncio.get_event_loop()
try:
while not self._closed and self._pty:
chunk = await loop.run_in_executor(None, self._pty.read, 4096)
if chunk is None:
break
data = chunk.encode("utf-8", errors="replace") if isinstance(chunk, str) else bytes(chunk)
if not data:
break
frame = self._make_frame(MSG_DATA, payload=data)
await self._send_frame(frame)
except asyncio.CancelledError:
pass
except Exception:
self.role._log("reverse_tunnel ps pty stdout pump error", error=True)
finally:
await self.stop(reason="stdout_closed")
async def _pump_pty_stdin(self) -> None:
loop = asyncio.get_event_loop()
try:
while not self._closed and self._pty:
try:
data = await self._stdin_queue.get()
except asyncio.CancelledError:
break
if data is None:
break
text = data.decode("utf-8", errors="replace") if isinstance(data, (bytes, bytearray)) else str(data)
try:
await loop.run_in_executor(None, self._pty.write, text)
except Exception:
break
except asyncio.CancelledError:
pass
except Exception:
self.role._log("reverse_tunnel ps pty stdin pump error", error=True)
finally:
await self.stop(reason="stdin_closed")
# No-op for pipe mode; resize is not supported here.
return
# -------------------- Pipe fallback pumps --------------------
async def _pump_proc_stdout(self) -> None:
@@ -258,11 +161,6 @@ class PowershellChannel:
if self._closed:
return
self._closed = True
if self._pty is not None:
try:
self._pty.terminate()
except Exception:
pass
if self._proc is not None:
try:
self._proc.terminate()

View File

@@ -27,7 +27,6 @@ pywinauto # Windows-based Macro Automation Library
sounddevice
numpy
pywin32; platform_system == "Windows"
pywinpty; platform_system == "Windows" # ConPTY bridge for reverse tunnel PowerShell sessions
# Ansible Libraries
ansible-core

View File

@@ -9,7 +9,6 @@ import {
MenuItem,
IconButton,
Tooltip,
Alert,
LinearProgress,
} from "@mui/material";
import {
@@ -102,8 +101,8 @@ export default function ReverseTunnelPowershell({ device }) {
const [connectionType, setConnectionType] = useState("ps");
const [tunnel, setTunnel] = useState(null);
const [sessionState, setSessionState] = useState("idle");
const [statusMessage, setStatusMessage] = useState("");
const [statusSeverity, setStatusSeverity] = useState("info");
const [, setStatusMessage] = useState("");
const [, setStatusSeverity] = useState("info");
const [output, setOutput] = useState("");
const [input, setInput] = useState("");
const [copyFlash, setCopyFlash] = useState(false);
@@ -242,14 +241,8 @@ export default function ReverseTunnelPowershell({ device }) {
setPolling(true);
pollTimerRef.current = setTimeout(async () => {
const resp = await emitAsync(socket, "ps_poll", {});
if (resp?.error) {
if (resp.error === "ps_unsupported") {
setStatusSeverity("info");
setStatusMessage("PowerShell channel warming up...");
} else {
setStatusSeverity("warning");
setStatusMessage(resp.error);
}
if (resp?.error) {
// Suppress warming/errors in UI; rely on session chips.
}
if (Array.isArray(resp?.output) && resp.output.length) {
appendOutput(resp.output.join(""));
@@ -375,11 +368,9 @@ export default function ReverseTunnelPowershell({ device }) {
const dims = measureTerminal();
const openResp = await emitAsync(socket, "ps_open", dims);
if (openResp?.error && openResp.error === "ps_unsupported") {
setStatusSeverity("info");
setStatusMessage("PowerShell channel warming up...");
// Suppress warming message; channel will settle once agent attaches.
}
appendOutput("");
setStatusMessage("Attached — waiting for agent to acknowledge...");
setSessionState("waiting_agent");
pollLoop(socket, lease.tunnel_id);
handleResize();
@@ -391,7 +382,7 @@ export default function ReverseTunnelPowershell({ device }) {
const requestTunnel = useCallback(async () => {
if (tunnel && sessionState !== "closed" && sessionState !== "idle") {
setStatusSeverity("info");
setStatusMessage("Re-attaching to existing tunnel...");
setStatusMessage("");
connectSocket(tunnel);
return;
}
@@ -407,8 +398,8 @@ export default function ReverseTunnelPowershell({ device }) {
}
resetState();
setSessionState("requesting");
setStatusSeverity("info");
setStatusMessage("Requesting tunnel lease...");
setStatusSeverity("info");
setStatusMessage("");
try {
const resp = await fetch("/api/tunnel/request", {
method: "POST",
@@ -420,21 +411,17 @@ export default function ReverseTunnelPowershell({ device }) {
const err = data?.error || `HTTP ${resp.status}`;
setSessionState("error");
setStatusSeverity(err === "domain_limit" ? "warning" : "error");
setStatusMessage(
err === "domain_limit"
? "PowerShell session already active for this agent. Try again after it closes."
: err
);
setStatusMessage("");
return;
}
setTunnel(data);
setStatusMessage("Lease issued. Waiting for agent to connect...");
setStatusMessage("");
setSessionState("lease_issued");
connectSocket(data);
} catch (e) {
setSessionState("error");
setStatusSeverity("error");
setStatusMessage(e?.message || "Failed to request tunnel");
setStatusMessage("");
}
}, [agentId, connectSocket, connectionType, resetState]);
@@ -448,7 +435,7 @@ export default function ReverseTunnelPowershell({ device }) {
const resp = await emitAsync(socket, "ps_send", { data: payload });
if (resp?.error) {
setStatusSeverity("warning");
setStatusMessage(resp.error);
setStatusMessage("");
}
},
[appendOutput, emitAsync]
@@ -583,20 +570,6 @@ export default function ReverseTunnelPowershell({ device }) {
</Stack>
</Box>
{statusMessage ? (
<Alert
severity={statusSeverity}
sx={{
borderRadius: 2,
backgroundColor: "rgba(8,12,24,0.9)",
border: `1px solid ${MAGIC_UI.panelBorder}`,
color: MAGIC_UI.textBright,
}}
>
{statusMessage}
</Alert>
) : null}
<Box
sx={{
flexGrow: 1,

View File

@@ -17,7 +17,7 @@ Read `Docs/Codex/FEATURE_IMPLEMENTATION_TRACKING/Agent_Reverse_Tunneling.md` and
- Keep the existing Socket.IO control channel untouched; this tunnel is a new dedicated listener/port.
- Reuse security: pinned TLS bundle + Ed25519 identity + existing token signing. Agent stays outbound-only; no inbound openings on devices.
- UI reminders specific to this feature: PowerShell page should mirror `Assemblies/Assembly_Editor.jsx` syntax highlighting and general layout from `Admin/Page_Template.jsx` per UI doc.
- Licensing: project is AGPL; pywinpty (MIT) is acceptable but must be attributed in Credits dialog.
- Licensing: project is AGPL; PowerShell runs in pipe mode (no pywinpty/ConPTY dependency).
- Non-destructive: new code must be gated/dormant until invoked; avoid regressions to existing roles/pages.
# Non-Destructive Expectations
@@ -30,7 +30,7 @@ Read `Docs/Codex/FEATURE_IMPLEMENTATION_TRACKING/Agent_Reverse_Tunneling.md` and
- Handshake: API on port 443 negotiates an ephemeral tunnel port + token/lease; Agent opens tunnel socket to that port; Engine maps operator channels to agent channels.
- Framing: Binary frames `version | msg_type | channel_id | flags | length | payload`; supports heartbeat, back-pressure, close codes, and resize events for terminals.
- Lease/idle: 1h idle timeout; 1h grace if agent drops mid-session before freeing port.
- PowerShell v1: Agent spawns ConPTY via pywinpty; Engine provides browser terminal bridge with syntax highlighting like `Assembly_Editor.jsx`.
- PowerShell v1: Agent spawns PowerShell via pipes (no ConPTY), Engine provides browser terminal bridge with syntax highlighting like `Assembly_Editor.jsx`.
# Terminology & IDs
- agent_id: existing composed ID (hostname + GUID + scope).
@@ -111,8 +111,8 @@ Read `Docs/Codex/FEATURE_IMPLEMENTATION_TRACKING/Agent_Reverse_Tunneling.md` and
- Heartbeat + idle tracking; stop_all closes active tunnels cleanly.
- Logging to `Agent/Logs/reverse_tunnel.log`.
- Submodules under `Data/Agent/Roles/ReverseTunnel/`:
- `tunnel_Powershell.py`: ConPTY/pywinpty, map stdin/out, handle resize control frames, exit codes.
- Common helpers: channel dispatcher, back-pressure (pause ConPTY reads if outbound buffer high).
- `tunnel_Powershell.py`: pipes-only PowerShell subprocess (stdin/stdout piping, control frames are no-ops for resize), exit codes.
- Common helpers: channel dispatcher, back-pressure (pause reads if outbound buffer high).
# PowerShell v1 (end-to-end)
- Engine:
@@ -197,7 +197,7 @@ Read `Docs/Codex/FEATURE_IMPLEMENTATION_TRACKING/Agent_Reverse_Tunneling.md` and
# Detailed Checklist (update statuses)
- [x] Repo hygiene
- [x] Confirm no conflicting changes; avoid touching legacy Socket.IO handlers.
- [x] Add pywinpty (MIT) to Agent deps (note potential packaging/test impact).
- [x] PowerShell transport: pipe-only (pywinpty/ConPTY removed from Agent deps).
- [x] Engine tunnel service
- [x] Add reverse tunnel config defaults (fixed port, port range, timeouts, log path) without enabling.
- [x] Create `Data/Engine/services/WebSocket/Agent/ReverseTunnel.py` (async/uvloop listener, port pool 3000040000).
@@ -216,11 +216,11 @@ Read `Docs/Codex/FEATURE_IMPLEMENTATION_TRACKING/Agent_Reverse_Tunneling.md` and
- [x] Integrate token validation, TLS reuse, idle teardown, and graceful stop_all.
- [ ] PowerShell v1 (feature target)
- [x] Engine side `Data/Engine/services/WebSocket/Agent/ReverseTunnel/Powershell.py` (channel server, resize handling, translate browser events).
- [x] Agent side `Data/Agent/Roles/ReverseTunnel/tunnel_Powershell.py` using ConPTY/pywinpty; map stdin/stdout to frames; handle resize and exit codes.
- [x] Agent side `Data/Agent/Roles/ReverseTunnel/tunnel_Powershell.py` using pipes-only PowerShell subprocess; map stdin/stdout to frames; resize no-op.
- [ ] WebUI: `Data/Engine/web-interface/src/ReverseTunnel/Powershell.jsx` with terminal UI, syntax highlighting matching `Assemblies/Assembly_Editor.jsx`, copy support, status toasts.
- [ ] Device Activity entries and UI surface in `Devices/Device_List.jsx` Device Activity tab.
- [ ] Credits & attribution
- [ ] If third-party libs used (e.g., pywinpty), add attribution in `Data/Engine/web-interface/src/Dialogs.jsx` CreditsDialog under “Code Shared in this Project”.
- [x] pywinpty removed (no attribution needed); revisit if new third-party deps added.
- [ ] Testing & validation
- [ ] Unit/behavioral tests for lease manager, framing, and idle teardown (Engine side).
- [ ] Agent role lifecycle tests (start/stop, reconnect, single-session enforcement).
@@ -245,7 +245,8 @@ Read `Docs/Codex/FEATURE_IMPLEMENTATION_TRACKING/Agent_Reverse_Tunneling.md` and
- 2025-11-30: Enabled async WebSocket listener per assigned port (TLS-aware via Engine certs) for agent CONNECT frames, with frame routing between agent socket and browser bridge queues; Engine tunnel service checklist marked complete.
- 2025-11-30: Added idle/grace sweeper, CONNECT_ACK to agents, heartbeat loop, and token-touched operator sends; per-port listener now runs on dedicated loop/thread. (Original instructions didnt call out sweeper/heartbeat wiring explicitly.)
- 2025-12-01: Added Agent reverse tunnel role (`Data/Agent/Roles/role_ReverseTunnel.py`) with TLS-aware WebSocket dialer, token validation against signed leases, domain-limit guard, heartbeat/idle watchdogs, and reverse_tunnel.log status emits; protocol handlers remain stubbed until PowerShell module lands.
- 2025-12-01: Implemented Agent PowerShell channel (pywinpty ConPTY stdin/stdout piping, resize, exit-close) and Engine PowerShell handler with Socket.IO helpers (`ps_open`/`ps_send`/`ps_resize`/`ps_poll`); added ps channel logging and domain-aware attach. WebUI remains pending.
- 2025-12-01: Implemented Agent PowerShell channel (initially pywinpty/ConPTY path, later simplified to pipes-only) and Engine PowerShell handler with Socket.IO helpers (`ps_open`/`ps_send`/`ps_resize`/`ps_poll`); added ps channel logging and domain-aware attach. WebUI remains pending.
- 2025-12-06: Simplified PowerShell handler to pipes-only, removed pywinpty dependency, added robust handler import for non-package agent runtimes, and cleaned UI status messaging.
## Engine Tunnel Service Architecture