mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-14 21:15:47 -07:00
Additional Reverse Shell code cleanup
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 30000–40000).
|
||||
@@ -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 didn’t 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user