mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 02:45:48 -07:00
Overhaul of Reverse Tunnel Code
This commit is contained in:
3
Data/Agent/Roles/Reverse_Tunnels/__init__.py
Normal file
3
Data/Agent/Roles/Reverse_Tunnels/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Namespace package for reverse tunnel domains (Agent side)."""
|
||||
|
||||
__all__ = ["remote_interactive_shell", "remote_management", "remote_video"]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Placeholder Bash channel (Agent side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
MSG_DATA = 0x05
|
||||
MSG_CONTROL = 0x09
|
||||
MSG_CLOSE = 0x08
|
||||
CLOSE_PROTOCOL_ERROR = 3
|
||||
CLOSE_AGENT_SHUTDOWN = 6
|
||||
|
||||
|
||||
class BashChannel:
|
||||
"""Stub Bash handler that immediately reports unsupported."""
|
||||
|
||||
def __init__(self, role, tunnel, channel_id: int, metadata: Optional[Dict[str, Any]]):
|
||||
self.role = role
|
||||
self.tunnel = tunnel
|
||||
self.channel_id = channel_id
|
||||
self.metadata = metadata or {}
|
||||
self.loop = getattr(role, "loop", None) or asyncio.get_event_loop()
|
||||
self._closed = False
|
||||
|
||||
async def start(self) -> None:
|
||||
# Until Bash support is implemented, close the channel to free resources.
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="bash_unsupported")
|
||||
|
||||
async def on_frame(self, frame) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
if frame.msg_type in (MSG_DATA, MSG_CONTROL):
|
||||
# Ignore payloads but acknowledge by stopping the channel to avoid leaks.
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="bash_unsupported")
|
||||
elif frame.msg_type == MSG_CLOSE:
|
||||
await self.stop(code=CLOSE_AGENT_SHUTDOWN, reason="operator_close")
|
||||
|
||||
async def stop(self, code: int = CLOSE_PROTOCOL_ERROR, reason: str = "") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
await self.role._send_frame(self.tunnel, self.role.close_frame(self.channel_id, code, reason or "bash_closed"))
|
||||
except Exception:
|
||||
pass
|
||||
self.role._log(f"reverse_tunnel bash channel stopped channel={self.channel_id} reason={reason or 'closed'}")
|
||||
|
||||
|
||||
__all__ = ["BashChannel"]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Expose the PowerShell channel under the domain path, with file-based import fallback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
powershell_module = None
|
||||
|
||||
# Attempt package-relative import first
|
||||
try: # pragma: no cover - best effort
|
||||
from ....ReverseTunnel import tunnel_Powershell as powershell_module # type: ignore
|
||||
except Exception:
|
||||
powershell_module = None
|
||||
|
||||
# Fallback: load directly from file path to survive non-package runtimes
|
||||
if powershell_module is None:
|
||||
try:
|
||||
base = Path(__file__).resolve().parents[3] / "ReverseTunnel" / "tunnel_Powershell.py"
|
||||
spec = importlib.util.spec_from_file_location("tunnel_Powershell", base)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore
|
||||
powershell_module = module
|
||||
except Exception:
|
||||
powershell_module = None
|
||||
|
||||
if powershell_module and hasattr(powershell_module, "PowershellChannel"):
|
||||
PowershellChannel = powershell_module.PowershellChannel # type: ignore
|
||||
else: # pragma: no cover - safety guard
|
||||
class PowershellChannel: # type: ignore
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError("PowerShell channel unavailable")
|
||||
|
||||
|
||||
__all__ = ["PowershellChannel"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Protocol handlers for interactive shell tunnels (Agent side)."""
|
||||
|
||||
from .Powershell import PowershellChannel
|
||||
from .Bash import BashChannel
|
||||
|
||||
__all__ = ["PowershellChannel", "BashChannel"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Interactive shell domain (PowerShell/Bash) handlers."""
|
||||
|
||||
__all__ = ["tunnel", "Protocols"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Placeholder module for remote interactive shell tunnel domain (Agent side)."""
|
||||
|
||||
DOMAIN_NAME = "remote-interactive-shell"
|
||||
|
||||
__all__ = ["DOMAIN_NAME"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Placeholder SSH channel (Agent side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
MSG_DATA = 0x05
|
||||
MSG_CONTROL = 0x09
|
||||
MSG_CLOSE = 0x08
|
||||
CLOSE_PROTOCOL_ERROR = 3
|
||||
CLOSE_AGENT_SHUTDOWN = 6
|
||||
|
||||
|
||||
class SSHChannel:
|
||||
"""Stub SSH handler that marks the channel unsupported for now."""
|
||||
|
||||
def __init__(self, role, tunnel, channel_id: int, metadata: Optional[Dict[str, Any]]):
|
||||
self.role = role
|
||||
self.tunnel = tunnel
|
||||
self.channel_id = channel_id
|
||||
self.metadata = metadata or {}
|
||||
self.loop = getattr(role, "loop", None) or asyncio.get_event_loop()
|
||||
self._closed = False
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="ssh_unsupported")
|
||||
|
||||
async def on_frame(self, frame) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
if frame.msg_type in (MSG_DATA, MSG_CONTROL):
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="ssh_unsupported")
|
||||
elif frame.msg_type == MSG_CLOSE:
|
||||
await self.stop(code=CLOSE_AGENT_SHUTDOWN, reason="operator_close")
|
||||
|
||||
async def stop(self, code: int = CLOSE_PROTOCOL_ERROR, reason: str = "") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
await self.role._send_frame(self.tunnel, self.role.close_frame(self.channel_id, code, reason or "ssh_closed"))
|
||||
except Exception:
|
||||
pass
|
||||
self.role._log(f"reverse_tunnel ssh channel stopped channel={self.channel_id} reason={reason or 'closed'}")
|
||||
|
||||
|
||||
__all__ = ["SSHChannel"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Placeholder WinRM channel (Agent side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
MSG_DATA = 0x05
|
||||
MSG_CONTROL = 0x09
|
||||
MSG_CLOSE = 0x08
|
||||
CLOSE_PROTOCOL_ERROR = 3
|
||||
CLOSE_AGENT_SHUTDOWN = 6
|
||||
|
||||
|
||||
class WinRMChannel:
|
||||
"""Stub WinRM handler that marks the channel unsupported for now."""
|
||||
|
||||
def __init__(self, role, tunnel, channel_id: int, metadata: Optional[Dict[str, Any]]):
|
||||
self.role = role
|
||||
self.tunnel = tunnel
|
||||
self.channel_id = channel_id
|
||||
self.metadata = metadata or {}
|
||||
self.loop = getattr(role, "loop", None) or asyncio.get_event_loop()
|
||||
self._closed = False
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="winrm_unsupported")
|
||||
|
||||
async def on_frame(self, frame) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
if frame.msg_type in (MSG_DATA, MSG_CONTROL):
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="winrm_unsupported")
|
||||
elif frame.msg_type == MSG_CLOSE:
|
||||
await self.stop(code=CLOSE_AGENT_SHUTDOWN, reason="operator_close")
|
||||
|
||||
async def stop(self, code: int = CLOSE_PROTOCOL_ERROR, reason: str = "") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
await self.role._send_frame(self.tunnel, self.role.close_frame(self.channel_id, code, reason or "winrm_closed"))
|
||||
except Exception:
|
||||
pass
|
||||
self.role._log(f"reverse_tunnel winrm channel stopped channel={self.channel_id} reason={reason or 'closed'}")
|
||||
|
||||
|
||||
__all__ = ["WinRMChannel"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Protocol handlers for remote management tunnels (Agent side)."""
|
||||
|
||||
from .SSH import SSHChannel
|
||||
from .WinRM import WinRMChannel
|
||||
|
||||
__all__ = ["SSHChannel", "WinRMChannel"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Remote management domain (SSH/WinRM) handlers."""
|
||||
|
||||
__all__ = ["tunnel", "Protocols"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Placeholder module for remote management domain (Agent side)."""
|
||||
|
||||
DOMAIN_NAME = "remote-management"
|
||||
|
||||
__all__ = ["DOMAIN_NAME"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Placeholder RDP channel (Agent side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
MSG_DATA = 0x05
|
||||
MSG_CONTROL = 0x09
|
||||
MSG_CLOSE = 0x08
|
||||
CLOSE_PROTOCOL_ERROR = 3
|
||||
CLOSE_AGENT_SHUTDOWN = 6
|
||||
|
||||
|
||||
class RDPChannel:
|
||||
"""Stub RDP handler that marks the channel unsupported for now."""
|
||||
|
||||
def __init__(self, role, tunnel, channel_id: int, metadata: Optional[Dict[str, Any]]):
|
||||
self.role = role
|
||||
self.tunnel = tunnel
|
||||
self.channel_id = channel_id
|
||||
self.metadata = metadata or {}
|
||||
self.loop = getattr(role, "loop", None) or asyncio.get_event_loop()
|
||||
self._closed = False
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="rdp_unsupported")
|
||||
|
||||
async def on_frame(self, frame) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
if frame.msg_type in (MSG_DATA, MSG_CONTROL):
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="rdp_unsupported")
|
||||
elif frame.msg_type == MSG_CLOSE:
|
||||
await self.stop(code=CLOSE_AGENT_SHUTDOWN, reason="operator_close")
|
||||
|
||||
async def stop(self, code: int = CLOSE_PROTOCOL_ERROR, reason: str = "") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
await self.role._send_frame(self.tunnel, self.role.close_frame(self.channel_id, code, reason or "rdp_closed"))
|
||||
except Exception:
|
||||
pass
|
||||
self.role._log(f"reverse_tunnel rdp channel stopped channel={self.channel_id} reason={reason or 'closed'}")
|
||||
|
||||
|
||||
__all__ = ["RDPChannel"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Placeholder VNC channel (Agent side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
MSG_DATA = 0x05
|
||||
MSG_CONTROL = 0x09
|
||||
MSG_CLOSE = 0x08
|
||||
CLOSE_PROTOCOL_ERROR = 3
|
||||
CLOSE_AGENT_SHUTDOWN = 6
|
||||
|
||||
|
||||
class VNCChannel:
|
||||
"""Stub VNC handler that marks the channel unsupported for now."""
|
||||
|
||||
def __init__(self, role, tunnel, channel_id: int, metadata: Optional[Dict[str, Any]]):
|
||||
self.role = role
|
||||
self.tunnel = tunnel
|
||||
self.channel_id = channel_id
|
||||
self.metadata = metadata or {}
|
||||
self.loop = getattr(role, "loop", None) or asyncio.get_event_loop()
|
||||
self._closed = False
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="vnc_unsupported")
|
||||
|
||||
async def on_frame(self, frame) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
if frame.msg_type in (MSG_DATA, MSG_CONTROL):
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="vnc_unsupported")
|
||||
elif frame.msg_type == MSG_CLOSE:
|
||||
await self.stop(code=CLOSE_AGENT_SHUTDOWN, reason="operator_close")
|
||||
|
||||
async def stop(self, code: int = CLOSE_PROTOCOL_ERROR, reason: str = "") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
await self.role._send_frame(self.tunnel, self.role.close_frame(self.channel_id, code, reason or "vnc_closed"))
|
||||
except Exception:
|
||||
pass
|
||||
self.role._log(f"reverse_tunnel vnc channel stopped channel={self.channel_id} reason={reason or 'closed'}")
|
||||
|
||||
|
||||
__all__ = ["VNCChannel"]
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Placeholder WebRTC channel (Agent side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
MSG_DATA = 0x05
|
||||
MSG_CONTROL = 0x09
|
||||
MSG_CLOSE = 0x08
|
||||
CLOSE_PROTOCOL_ERROR = 3
|
||||
CLOSE_AGENT_SHUTDOWN = 6
|
||||
|
||||
|
||||
class WebRTCChannel:
|
||||
"""Stub WebRTC handler that marks the channel unsupported for now."""
|
||||
|
||||
def __init__(self, role, tunnel, channel_id: int, metadata: Optional[Dict[str, Any]]):
|
||||
self.role = role
|
||||
self.tunnel = tunnel
|
||||
self.channel_id = channel_id
|
||||
self.metadata = metadata or {}
|
||||
self.loop = getattr(role, "loop", None) or asyncio.get_event_loop()
|
||||
self._closed = False
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="webrtc_unsupported")
|
||||
|
||||
async def on_frame(self, frame) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
if frame.msg_type in (MSG_DATA, MSG_CONTROL):
|
||||
await self.stop(code=CLOSE_PROTOCOL_ERROR, reason="webrtc_unsupported")
|
||||
elif frame.msg_type == MSG_CLOSE:
|
||||
await self.stop(code=CLOSE_AGENT_SHUTDOWN, reason="operator_close")
|
||||
|
||||
async def stop(self, code: int = CLOSE_PROTOCOL_ERROR, reason: str = "") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
await self.role._send_frame(self.tunnel, self.role.close_frame(self.channel_id, code, reason or "webrtc_closed"))
|
||||
except Exception:
|
||||
pass
|
||||
self.role._log(f"reverse_tunnel webrtc channel stopped channel={self.channel_id} reason={reason or 'closed'}")
|
||||
|
||||
|
||||
__all__ = ["WebRTCChannel"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Protocol handlers for remote video tunnels (Agent side)."""
|
||||
|
||||
from .WebRTC import WebRTCChannel
|
||||
from .RDP import RDPChannel
|
||||
from .VNC import VNCChannel
|
||||
|
||||
__all__ = ["WebRTCChannel", "RDPChannel", "VNCChannel"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Remote video/desktop domain (RDP/VNC/WebRTC) handlers."""
|
||||
|
||||
__all__ = ["tunnel", "Protocols"]
|
||||
5
Data/Agent/Roles/Reverse_Tunnels/remote_video/tunnel.py
Normal file
5
Data/Agent/Roles/Reverse_Tunnels/remote_video/tunnel.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Placeholder module for remote video domain (Agent side)."""
|
||||
|
||||
DOMAIN_NAME = "remote-video"
|
||||
|
||||
__all__ = ["DOMAIN_NAME"]
|
||||
@@ -14,25 +14,109 @@ import aiohttp
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
# Capture import errors for the PowerShell handler so we can report why it is missing.
|
||||
# Capture import errors for protocol handlers so we can report why they are missing.
|
||||
PS_IMPORT_ERROR: Optional[str] = None
|
||||
BASH_IMPORT_ERROR: Optional[str] = None
|
||||
tunnel_SSH = None
|
||||
tunnel_WinRM = None
|
||||
tunnel_VNC = None
|
||||
tunnel_RDP = None
|
||||
tunnel_WebRTC = None
|
||||
tunnel_Powershell = None
|
||||
tunnel_Bash = None
|
||||
|
||||
|
||||
def _load_protocol_module(module_name: str, rel_parts: list[str]) -> tuple[Optional[object], Optional[str]]:
|
||||
"""Load a protocol handler directly from a file path to survive non-package runtimes."""
|
||||
|
||||
base = Path(__file__).parent
|
||||
path = base
|
||||
for part in rel_parts:
|
||||
path = path / part
|
||||
if not path.exists():
|
||||
return None, f"path_missing:{path}"
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if not spec or not spec.loader:
|
||||
return None, "spec_failed"
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore
|
||||
return module, None
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
return None, repr(exc)
|
||||
|
||||
|
||||
try:
|
||||
from .ReverseTunnel import tunnel_Powershell # type: ignore
|
||||
from .Reverse_Tunnels.remote_interactive_shell.Protocols import Powershell as tunnel_Powershell # type: ignore
|
||||
except Exception as exc: # pragma: no cover - best-effort logging only
|
||||
PS_IMPORT_ERROR = repr(exc)
|
||||
# Try manual import from file to survive non-package execution.
|
||||
try:
|
||||
_ps_path = Path(__file__).parent / "ReverseTunnel" / "tunnel_Powershell.py"
|
||||
if _ps_path.exists():
|
||||
spec = importlib.util.spec_from_file_location("tunnel_Powershell", _ps_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore
|
||||
tunnel_Powershell = module
|
||||
PS_IMPORT_ERROR = None
|
||||
except Exception as exc2: # pragma: no cover - diagnostic only
|
||||
PS_IMPORT_ERROR = f"{PS_IMPORT_ERROR} | fallback_load_failed={exc2!r}"
|
||||
_module, _err = _load_protocol_module(
|
||||
"tunnel_Powershell",
|
||||
["Reverse_Tunnels", "remote_interactive_shell", "Protocols", "Powershell.py"],
|
||||
)
|
||||
if _module:
|
||||
tunnel_Powershell = _module
|
||||
PS_IMPORT_ERROR = None
|
||||
else:
|
||||
try:
|
||||
from .ReverseTunnel import tunnel_Powershell # type: ignore # legacy fallback
|
||||
PS_IMPORT_ERROR = None
|
||||
except Exception as exc2: # pragma: no cover - diagnostic only
|
||||
PS_IMPORT_ERROR = f"{PS_IMPORT_ERROR} | legacy_fallback={exc2!r} | file_load_failed={_err}"
|
||||
|
||||
try:
|
||||
from .Reverse_Tunnels.remote_interactive_shell.Protocols import Bash as tunnel_Bash # type: ignore
|
||||
except Exception as exc: # pragma: no cover - best-effort logging only
|
||||
BASH_IMPORT_ERROR = repr(exc)
|
||||
_module, _err = _load_protocol_module(
|
||||
"tunnel_Bash",
|
||||
["Reverse_Tunnels", "remote_interactive_shell", "Protocols", "Bash.py"],
|
||||
)
|
||||
if _module:
|
||||
tunnel_Bash = _module
|
||||
BASH_IMPORT_ERROR = None
|
||||
else:
|
||||
BASH_IMPORT_ERROR = f"{BASH_IMPORT_ERROR} | file_load_failed={_err}"
|
||||
try:
|
||||
from .Reverse_Tunnels.remote_management.Protocols import SSH as tunnel_SSH # type: ignore
|
||||
except Exception:
|
||||
_module, _err = _load_protocol_module(
|
||||
"tunnel_SSH",
|
||||
["Reverse_Tunnels", "remote_management", "Protocols", "SSH.py"],
|
||||
)
|
||||
tunnel_SSH = _module
|
||||
try:
|
||||
from .Reverse_Tunnels.remote_management.Protocols import WinRM as tunnel_WinRM # type: ignore
|
||||
except Exception:
|
||||
_module, _err = _load_protocol_module(
|
||||
"tunnel_WinRM",
|
||||
["Reverse_Tunnels", "remote_management", "Protocols", "WinRM.py"],
|
||||
)
|
||||
tunnel_WinRM = _module
|
||||
try:
|
||||
from .Reverse_Tunnels.remote_video.Protocols import VNC as tunnel_VNC # type: ignore
|
||||
except Exception:
|
||||
_module, _err = _load_protocol_module(
|
||||
"tunnel_VNC",
|
||||
["Reverse_Tunnels", "remote_video", "Protocols", "VNC.py"],
|
||||
)
|
||||
tunnel_VNC = _module
|
||||
try:
|
||||
from .Reverse_Tunnels.remote_video.Protocols import RDP as tunnel_RDP # type: ignore
|
||||
except Exception:
|
||||
_module, _err = _load_protocol_module(
|
||||
"tunnel_RDP",
|
||||
["Reverse_Tunnels", "remote_video", "Protocols", "RDP.py"],
|
||||
)
|
||||
tunnel_RDP = _module
|
||||
try:
|
||||
from .Reverse_Tunnels.remote_video.Protocols import WebRTC as tunnel_WebRTC # type: ignore
|
||||
except Exception:
|
||||
_module, _err = _load_protocol_module(
|
||||
"tunnel_WebRTC",
|
||||
["Reverse_Tunnels", "remote_video", "Protocols", "WebRTC.py"],
|
||||
)
|
||||
tunnel_WebRTC = _module
|
||||
|
||||
ROLE_NAME = "reverse_tunnel"
|
||||
ROLE_CONTEXTS = ["interactive", "system"]
|
||||
@@ -185,10 +269,14 @@ class Role:
|
||||
self._active: Dict[str, ActiveTunnel] = {}
|
||||
self._domain_claims: Dict[str, str] = {}
|
||||
self._domain_limits: Dict[str, Optional[int]] = {
|
||||
"ps": 1,
|
||||
"remote-interactive-shell": 2,
|
||||
"remote-management": 1,
|
||||
"remote-video": 2,
|
||||
# Legacy / protocol fallbacks
|
||||
"ps": 2,
|
||||
"rdp": 1,
|
||||
"vnc": 1,
|
||||
"webrtc": 1,
|
||||
"webrtc": 2,
|
||||
"ssh": None,
|
||||
"winrm": None,
|
||||
}
|
||||
@@ -211,6 +299,28 @@ class Role:
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log(f"reverse_tunnel ps handler registration failed: {exc}", error=True)
|
||||
try:
|
||||
if tunnel_Bash and hasattr(tunnel_Bash, "BashChannel"):
|
||||
self._protocol_handlers["bash"] = tunnel_Bash.BashChannel
|
||||
module_path = getattr(tunnel_Bash, "__file__", None)
|
||||
self._log(f"reverse_tunnel bash handler registered (BashChannel) module={module_path}")
|
||||
elif BASH_IMPORT_ERROR:
|
||||
self._log(f"reverse_tunnel bash handler NOT registered (missing module/class) import_error={BASH_IMPORT_ERROR}", error=True)
|
||||
except Exception as exc:
|
||||
self._log(f"reverse_tunnel bash handler registration failed: {exc}", error=True)
|
||||
try:
|
||||
if tunnel_SSH and hasattr(tunnel_SSH, "SSHChannel"):
|
||||
self._protocol_handlers["ssh"] = tunnel_SSH.SSHChannel
|
||||
if tunnel_WinRM and hasattr(tunnel_WinRM, "WinRMChannel"):
|
||||
self._protocol_handlers["winrm"] = tunnel_WinRM.WinRMChannel
|
||||
if tunnel_VNC and hasattr(tunnel_VNC, "VNCChannel"):
|
||||
self._protocol_handlers["vnc"] = tunnel_VNC.VNCChannel
|
||||
if tunnel_RDP and hasattr(tunnel_RDP, "RDPChannel"):
|
||||
self._protocol_handlers["rdp"] = tunnel_RDP.RDPChannel
|
||||
if tunnel_WebRTC and hasattr(tunnel_WebRTC, "WebRTCChannel"):
|
||||
self._protocol_handlers["webrtc"] = tunnel_WebRTC.WebRTCChannel
|
||||
except Exception as exc:
|
||||
self._log(f"reverse_tunnel protocol handler registration failed: {exc}", error=True)
|
||||
|
||||
# ------------------------------------------------------------------ Logging
|
||||
def _log(self, message: str, *, error: bool = False) -> None:
|
||||
@@ -728,6 +838,7 @@ class Role:
|
||||
try:
|
||||
handler = handler_cls(self, tunnel, frame.channel_id, metadata)
|
||||
except Exception:
|
||||
self._log(f"reverse_tunnel channel handler fallback to BaseChannel protocol={protocol}", error=True)
|
||||
handler = BaseChannel(self, tunnel, frame.channel_id, metadata)
|
||||
tunnel.channels[frame.channel_id] = handler
|
||||
await handler.start()
|
||||
|
||||
@@ -56,7 +56,7 @@ def test_reverse_tunnel_powershell_roundtrip() -> None:
|
||||
# 1) Request a tunnel lease
|
||||
resp = sess.post(
|
||||
f"{HOST}/api/tunnel/request",
|
||||
json={"agent_id": AGENT_ID, "protocol": "ps", "domain": "ps"},
|
||||
json={"agent_id": AGENT_ID, "protocol": "ps", "domain": "remote-interactive-shell"},
|
||||
)
|
||||
assert resp.status_code == 200, f"lease request failed: {resp.status_code} {resp.text}"
|
||||
lease = resp.json()
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Any, Dict, Optional, Tuple
|
||||
from flask import Blueprint, jsonify, request, session
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from ...WebSocket.Agent.ReverseTunnel import ReverseTunnelService
|
||||
from ...WebSocket.Agent.reverse_tunnel_orchestrator import ReverseTunnelService
|
||||
|
||||
if False: # pragma: no cover - import cycle hint for type checkers
|
||||
from .. import EngineServiceAdapters
|
||||
@@ -103,6 +103,8 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
||||
agent_id = _normalize_text(body.get("agent_id"))
|
||||
protocol = _normalize_text(body.get("protocol") or "ps").lower() or "ps"
|
||||
domain = _normalize_text(body.get("domain") or protocol).lower() or protocol
|
||||
if protocol == "ps" and domain == "ps":
|
||||
domain = "remote-interactive-shell"
|
||||
|
||||
if not agent_id:
|
||||
return jsonify({"error": "agent_id_required"}), 400
|
||||
@@ -135,4 +137,33 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
||||
)
|
||||
return jsonify(summary), 200
|
||||
|
||||
@blueprint.route("/api/tunnel/<tunnel_id>", methods=["DELETE"])
|
||||
def stop_tunnel(tunnel_id: str):
|
||||
requirement = _require_login(app)
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
tunnel_id_norm = _normalize_text(tunnel_id)
|
||||
if not tunnel_id_norm:
|
||||
return jsonify({"error": "tunnel_id_required"}), 400
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
reason = _normalize_text(body.get("reason") or "operator_stop")
|
||||
|
||||
tunnel_service = _get_tunnel_service(adapters)
|
||||
stopped = False
|
||||
try:
|
||||
stopped = tunnel_service.stop_tunnel(tunnel_id_norm, reason=reason)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("stop_tunnel failed tunnel_id=%s: %s", tunnel_id_norm, exc, exc_info=True)
|
||||
if not stopped:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
|
||||
service_log(
|
||||
"reverse_tunnel",
|
||||
f"lease stopped tunnel_id={tunnel_id_norm} reason={reason or '-'}",
|
||||
)
|
||||
return jsonify({"status": "stopped", "tunnel_id": tunnel_id_norm}), 200
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Protocol-specific helpers for Reverse Tunnel (Engine side)."""
|
||||
|
||||
from .Powershell import PowershellChannelServer
|
||||
|
||||
__all__ = ["PowershellChannelServer"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Namespace package for reverse tunnel domain handlers (Engine side)."""
|
||||
|
||||
__all__ = ["remote_interactive_shell", "remote_management", "remote_video"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Placeholder Bash channel server (Engine side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
class BashChannelServer:
|
||||
"""Stub Bash handler until the agent-side channel is implemented."""
|
||||
|
||||
protocol_name = "bash"
|
||||
|
||||
def __init__(self, bridge, service, *, channel_id: int = 1, frame_cls=None, close_frame_fn=None):
|
||||
self.bridge = bridge
|
||||
self.service = service
|
||||
self.channel_id = channel_id
|
||||
self.logger = service.logger.getChild(f"bash.{bridge.lease.tunnel_id}")
|
||||
self._open_sent = False
|
||||
self._ack_received = False
|
||||
self._closed = False
|
||||
self._output = deque()
|
||||
self._frame_cls = frame_cls
|
||||
self._close_frame_fn = close_frame_fn
|
||||
|
||||
def handle_agent_frame(self, frame) -> None:
|
||||
# No-op placeholder; output collection for future Bash support.
|
||||
try:
|
||||
if frame.msg_type == 0x04: # MSG_CHANNEL_ACK
|
||||
self._ack_received = True
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def open_channel(self, *, cols: int = 120, rows: int = 32) -> None:
|
||||
self._open_sent = True
|
||||
self.logger.info(
|
||||
"bash channel placeholder open sent tunnel_id=%s channel_id=%s cols=%s rows=%s",
|
||||
self.bridge.lease.tunnel_id,
|
||||
self.channel_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
|
||||
def send_input(self, data: str) -> None:
|
||||
# Placeholder: no agent-side Bash yet.
|
||||
self.logger.info("bash placeholder send_input ignored tunnel_id=%s", self.bridge.lease.tunnel_id)
|
||||
|
||||
def send_resize(self, cols: int, rows: int) -> None:
|
||||
# Placeholder: not implemented.
|
||||
return
|
||||
|
||||
def close(self, code: int = 6, reason: str = "operator_close") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if callable(self._close_frame_fn):
|
||||
try:
|
||||
frame = self._close_frame_fn(self.channel_id, code, reason)
|
||||
self.bridge.operator_to_agent(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def drain_output(self):
|
||||
items = []
|
||||
while self._output:
|
||||
items.append(self._output.popleft())
|
||||
return items
|
||||
|
||||
def status(self):
|
||||
return {
|
||||
"channel_id": self.channel_id,
|
||||
"open_sent": self._open_sent,
|
||||
"ack": self._ack_received,
|
||||
"closed": self._closed,
|
||||
"close_reason": None,
|
||||
"close_code": None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["BashChannelServer"]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Engine-side PowerShell tunnel channel helper."""
|
||||
"""Engine-side PowerShell tunnel channel helper (remote interactive shell domain)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -134,3 +134,6 @@ class PowershellChannelServer:
|
||||
"close_reason": self._close_reason,
|
||||
"close_code": self._close_code,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["PowershellChannelServer"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Protocol handlers for remote interactive shell tunnels (Engine side)."""
|
||||
|
||||
from .Powershell import PowershellChannelServer
|
||||
from .Bash import BashChannelServer
|
||||
|
||||
__all__ = ["PowershellChannelServer", "BashChannelServer"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Domain handlers for remote interactive shells (PowerShell/Bash)."""
|
||||
|
||||
__all__ = ["Protocols"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Placeholder SSH channel server (Engine side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
class SSHChannelServer:
|
||||
protocol_name = "ssh"
|
||||
|
||||
def __init__(self, bridge, service, *, channel_id: int = 1, frame_cls=None, close_frame_fn=None):
|
||||
self.bridge = bridge
|
||||
self.service = service
|
||||
self.channel_id = channel_id
|
||||
self.logger = service.logger.getChild(f"ssh.{bridge.lease.tunnel_id}")
|
||||
self._open_sent = False
|
||||
self._ack_received = False
|
||||
self._closed = False
|
||||
self._output = deque()
|
||||
self._frame_cls = frame_cls
|
||||
self._close_frame_fn = close_frame_fn
|
||||
|
||||
def handle_agent_frame(self, frame) -> None:
|
||||
try:
|
||||
if frame.msg_type == 0x04: # MSG_CHANNEL_ACK
|
||||
self._ack_received = True
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def open_channel(self, *, cols: int = 120, rows: int = 32) -> None:
|
||||
self._open_sent = True
|
||||
self.logger.info(
|
||||
"ssh channel placeholder open sent tunnel_id=%s channel_id=%s cols=%s rows=%s",
|
||||
self.bridge.lease.tunnel_id,
|
||||
self.channel_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
|
||||
def send_input(self, data: str) -> None:
|
||||
self.logger.info("ssh placeholder send_input ignored tunnel_id=%s", self.bridge.lease.tunnel_id)
|
||||
|
||||
def send_resize(self, cols: int, rows: int) -> None:
|
||||
return
|
||||
|
||||
def close(self, code: int = 6, reason: str = "operator_close") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if callable(self._close_frame_fn):
|
||||
try:
|
||||
frame = self._close_frame_fn(self.channel_id, code, reason)
|
||||
self.bridge.operator_to_agent(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def drain_output(self):
|
||||
items = []
|
||||
while self._output:
|
||||
items.append(self._output.popleft())
|
||||
return items
|
||||
|
||||
def status(self):
|
||||
return {
|
||||
"channel_id": self.channel_id,
|
||||
"open_sent": self._open_sent,
|
||||
"ack": self._ack_received,
|
||||
"closed": self._closed,
|
||||
"close_reason": None,
|
||||
"close_code": None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["SSHChannelServer"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Placeholder WinRM channel server (Engine side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
class WinRMChannelServer:
|
||||
protocol_name = "winrm"
|
||||
|
||||
def __init__(self, bridge, service, *, channel_id: int = 1, frame_cls=None, close_frame_fn=None):
|
||||
self.bridge = bridge
|
||||
self.service = service
|
||||
self.channel_id = channel_id
|
||||
self.logger = service.logger.getChild(f"winrm.{bridge.lease.tunnel_id}")
|
||||
self._open_sent = False
|
||||
self._ack_received = False
|
||||
self._closed = False
|
||||
self._output = deque()
|
||||
self._frame_cls = frame_cls
|
||||
self._close_frame_fn = close_frame_fn
|
||||
|
||||
def handle_agent_frame(self, frame) -> None:
|
||||
try:
|
||||
if frame.msg_type == 0x04: # MSG_CHANNEL_ACK
|
||||
self._ack_received = True
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def open_channel(self, *, cols: int = 120, rows: int = 32) -> None:
|
||||
self._open_sent = True
|
||||
self.logger.info(
|
||||
"winrm channel placeholder open sent tunnel_id=%s channel_id=%s cols=%s rows=%s",
|
||||
self.bridge.lease.tunnel_id,
|
||||
self.channel_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
|
||||
def send_input(self, data: str) -> None:
|
||||
self.logger.info("winrm placeholder send_input ignored tunnel_id=%s", self.bridge.lease.tunnel_id)
|
||||
|
||||
def send_resize(self, cols: int, rows: int) -> None:
|
||||
return
|
||||
|
||||
def close(self, code: int = 6, reason: str = "operator_close") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if callable(self._close_frame_fn):
|
||||
try:
|
||||
frame = self._close_frame_fn(self.channel_id, code, reason)
|
||||
self.bridge.operator_to_agent(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def drain_output(self):
|
||||
items = []
|
||||
while self._output:
|
||||
items.append(self._output.popleft())
|
||||
return items
|
||||
|
||||
def status(self):
|
||||
return {
|
||||
"channel_id": self.channel_id,
|
||||
"open_sent": self._open_sent,
|
||||
"ack": self._ack_received,
|
||||
"closed": self._closed,
|
||||
"close_reason": None,
|
||||
"close_code": None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["WinRMChannelServer"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Protocol handlers for remote management tunnels (Engine side)."""
|
||||
|
||||
from .SSH import SSHChannelServer
|
||||
from .WinRM import WinRMChannelServer
|
||||
|
||||
__all__ = ["SSHChannelServer", "WinRMChannelServer"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Domain handlers for remote management tunnels (SSH/WinRM)."""
|
||||
|
||||
__all__ = ["Protocols"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Placeholder RDP channel server (Engine side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
class RDPChannelServer:
|
||||
protocol_name = "rdp"
|
||||
|
||||
def __init__(self, bridge, service, *, channel_id: int = 1, frame_cls=None, close_frame_fn=None):
|
||||
self.bridge = bridge
|
||||
self.service = service
|
||||
self.channel_id = channel_id
|
||||
self.logger = service.logger.getChild(f"rdp.{bridge.lease.tunnel_id}")
|
||||
self._open_sent = False
|
||||
self._ack_received = False
|
||||
self._closed = False
|
||||
self._output = deque()
|
||||
self._frame_cls = frame_cls
|
||||
self._close_frame_fn = close_frame_fn
|
||||
|
||||
def handle_agent_frame(self, frame) -> None:
|
||||
try:
|
||||
if frame.msg_type == 0x04: # MSG_CHANNEL_ACK
|
||||
self._ack_received = True
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def open_channel(self, *, cols: int = 120, rows: int = 32) -> None:
|
||||
self._open_sent = True
|
||||
self.logger.info(
|
||||
"rdp channel placeholder open sent tunnel_id=%s channel_id=%s cols=%s rows=%s",
|
||||
self.bridge.lease.tunnel_id,
|
||||
self.channel_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
|
||||
def send_input(self, data: str) -> None:
|
||||
self.logger.info("rdp placeholder send_input ignored tunnel_id=%s", self.bridge.lease.tunnel_id)
|
||||
|
||||
def send_resize(self, cols: int, rows: int) -> None:
|
||||
return
|
||||
|
||||
def close(self, code: int = 6, reason: str = "operator_close") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if callable(self._close_frame_fn):
|
||||
try:
|
||||
frame = self._close_frame_fn(self.channel_id, code, reason)
|
||||
self.bridge.operator_to_agent(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def drain_output(self):
|
||||
items = []
|
||||
while self._output:
|
||||
items.append(self._output.popleft())
|
||||
return items
|
||||
|
||||
def status(self):
|
||||
return {
|
||||
"channel_id": self.channel_id,
|
||||
"open_sent": self._open_sent,
|
||||
"ack": self._ack_received,
|
||||
"closed": self._closed,
|
||||
"close_reason": None,
|
||||
"close_code": None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["RDPChannelServer"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Placeholder VNC channel server (Engine side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
class VNCChannelServer:
|
||||
protocol_name = "vnc"
|
||||
|
||||
def __init__(self, bridge, service, *, channel_id: int = 1, frame_cls=None, close_frame_fn=None):
|
||||
self.bridge = bridge
|
||||
self.service = service
|
||||
self.channel_id = channel_id
|
||||
self.logger = service.logger.getChild(f"vnc.{bridge.lease.tunnel_id}")
|
||||
self._open_sent = False
|
||||
self._ack_received = False
|
||||
self._closed = False
|
||||
self._output = deque()
|
||||
self._frame_cls = frame_cls
|
||||
self._close_frame_fn = close_frame_fn
|
||||
|
||||
def handle_agent_frame(self, frame) -> None:
|
||||
try:
|
||||
if frame.msg_type == 0x04: # MSG_CHANNEL_ACK
|
||||
self._ack_received = True
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def open_channel(self, *, cols: int = 120, rows: int = 32) -> None:
|
||||
self._open_sent = True
|
||||
self.logger.info(
|
||||
"vnc channel placeholder open sent tunnel_id=%s channel_id=%s cols=%s rows=%s",
|
||||
self.bridge.lease.tunnel_id,
|
||||
self.channel_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
|
||||
def send_input(self, data: str) -> None:
|
||||
self.logger.info("vnc placeholder send_input ignored tunnel_id=%s", self.bridge.lease.tunnel_id)
|
||||
|
||||
def send_resize(self, cols: int, rows: int) -> None:
|
||||
return
|
||||
|
||||
def close(self, code: int = 6, reason: str = "operator_close") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if callable(self._close_frame_fn):
|
||||
try:
|
||||
frame = self._close_frame_fn(self.channel_id, code, reason)
|
||||
self.bridge.operator_to_agent(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def drain_output(self):
|
||||
items = []
|
||||
while self._output:
|
||||
items.append(self._output.popleft())
|
||||
return items
|
||||
|
||||
def status(self):
|
||||
return {
|
||||
"channel_id": self.channel_id,
|
||||
"open_sent": self._open_sent,
|
||||
"ack": self._ack_received,
|
||||
"closed": self._closed,
|
||||
"close_reason": None,
|
||||
"close_code": None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["VNCChannelServer"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Placeholder WebRTC channel server (Engine side)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
|
||||
class WebRTCChannelServer:
|
||||
protocol_name = "webrtc"
|
||||
|
||||
def __init__(self, bridge, service, *, channel_id: int = 1, frame_cls=None, close_frame_fn=None):
|
||||
self.bridge = bridge
|
||||
self.service = service
|
||||
self.channel_id = channel_id
|
||||
self.logger = service.logger.getChild(f"webrtc.{bridge.lease.tunnel_id}")
|
||||
self._open_sent = False
|
||||
self._ack_received = False
|
||||
self._closed = False
|
||||
self._output = deque()
|
||||
self._frame_cls = frame_cls
|
||||
self._close_frame_fn = close_frame_fn
|
||||
|
||||
def handle_agent_frame(self, frame) -> None:
|
||||
try:
|
||||
if frame.msg_type == 0x04: # MSG_CHANNEL_ACK
|
||||
self._ack_received = True
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def open_channel(self, *, cols: int = 120, rows: int = 32) -> None:
|
||||
self._open_sent = True
|
||||
self.logger.info(
|
||||
"webrtc channel placeholder open sent tunnel_id=%s channel_id=%s cols=%s rows=%s",
|
||||
self.bridge.lease.tunnel_id,
|
||||
self.channel_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
|
||||
def send_input(self, data: str) -> None:
|
||||
self.logger.info("webrtc placeholder send_input ignored tunnel_id=%s", self.bridge.lease.tunnel_id)
|
||||
|
||||
def send_resize(self, cols: int, rows: int) -> None:
|
||||
return
|
||||
|
||||
def close(self, code: int = 6, reason: str = "operator_close") -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
if callable(self._close_frame_fn):
|
||||
try:
|
||||
frame = self._close_frame_fn(self.channel_id, code, reason)
|
||||
self.bridge.operator_to_agent(frame)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def drain_output(self):
|
||||
items = []
|
||||
while self._output:
|
||||
items.append(self._output.popleft())
|
||||
return items
|
||||
|
||||
def status(self):
|
||||
return {
|
||||
"channel_id": self.channel_id,
|
||||
"open_sent": self._open_sent,
|
||||
"ack": self._ack_received,
|
||||
"closed": self._closed,
|
||||
"close_reason": None,
|
||||
"close_code": None,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["WebRTCChannelServer"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Protocol handlers for remote video tunnels (Engine side)."""
|
||||
|
||||
from .WebRTC import WebRTCChannelServer
|
||||
from .RDP import RDPChannelServer
|
||||
from .VNC import VNCChannelServer
|
||||
|
||||
__all__ = ["WebRTCChannelServer", "RDPChannelServer", "VNCChannelServer"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Domain handlers for remote video/desktop tunnels (RDP/VNC/WebRTC)."""
|
||||
|
||||
__all__ = ["Protocols"]
|
||||
@@ -1,5 +1,5 @@
|
||||
# ======================================================
|
||||
# Data\Engine\services\WebSocket\Agent\ReverseTunnel.py
|
||||
# Data\Engine\services\WebSocket\Agent\reverse_tunnel_orchestrator.py
|
||||
# Description: Async reverse tunnel scaffolding (Engine side) providing lease management, domain limits, and placeholders for WebSocket listeners.
|
||||
#
|
||||
# API Endpoints (if applicable): None
|
||||
@@ -29,7 +29,13 @@ from typing import Callable, Deque, Dict, Iterable, List, Optional, Tuple
|
||||
from collections import deque
|
||||
from threading import Thread
|
||||
|
||||
from .ReverseTunnelProtocols import PowershellChannelServer
|
||||
from .Reverse_Tunnels.remote_interactive_shell.Protocols.Powershell import PowershellChannelServer
|
||||
from .Reverse_Tunnels.remote_interactive_shell.Protocols.Bash import BashChannelServer
|
||||
from .Reverse_Tunnels.remote_management.Protocols.SSH import SSHChannelServer
|
||||
from .Reverse_Tunnels.remote_management.Protocols.WinRM import WinRMChannelServer
|
||||
from .Reverse_Tunnels.remote_video.Protocols.VNC import VNCChannelServer
|
||||
from .Reverse_Tunnels.remote_video.Protocols.RDP import RDPChannelServer
|
||||
from .Reverse_Tunnels.remote_video.Protocols.WebRTC import WebRTCChannelServer
|
||||
|
||||
try: # websockets is added to engine requirements
|
||||
import websockets
|
||||
@@ -234,10 +240,15 @@ class DomainPolicy:
|
||||
"""Enforce per-domain concurrency and defaults."""
|
||||
|
||||
DEFAULT_LIMITS = {
|
||||
"ps": 1,
|
||||
# New domain lanes
|
||||
"remote-interactive-shell": 2,
|
||||
"remote-management": 1,
|
||||
"remote-video": 2,
|
||||
# Protocol-specific fallbacks (for backward compatibility / legacy callers)
|
||||
"ps": 2,
|
||||
"rdp": 1,
|
||||
"vnc": 1,
|
||||
"webrtc": 1,
|
||||
"webrtc": 2,
|
||||
"ssh": None, # Unlimited
|
||||
"winrm": None, # Unlimited
|
||||
}
|
||||
@@ -459,7 +470,17 @@ class ReverseTunnelService:
|
||||
self._bridges: Dict[str, "TunnelBridge"] = {}
|
||||
self._port_servers: Dict[int, asyncio.AbstractServer] = {}
|
||||
self._agent_sockets: Dict[str, "websockets.WebSocketServerProtocol"] = {}
|
||||
self._ps_servers: Dict[str, PowershellChannelServer] = {}
|
||||
self.protocol_registry = {
|
||||
"ps": PowershellChannelServer,
|
||||
"powershell": PowershellChannelServer,
|
||||
"bash": BashChannelServer,
|
||||
"ssh": SSHChannelServer,
|
||||
"winrm": WinRMChannelServer,
|
||||
"vnc": VNCChannelServer,
|
||||
"rdp": RDPChannelServer,
|
||||
"webrtc": WebRTCChannelServer,
|
||||
}
|
||||
self._protocol_servers: Dict[str, object] = {}
|
||||
|
||||
def _ensure_loop(self) -> None:
|
||||
if self._running and self._loop:
|
||||
@@ -504,6 +525,12 @@ class ReverseTunnelService:
|
||||
self._loop.call_soon_threadsafe(asyncio.create_task, websocket.close())
|
||||
except Exception:
|
||||
pass
|
||||
for tunnel_id in list(self._bridges.keys()):
|
||||
try:
|
||||
self.release_bridge(tunnel_id, reason="service_stop")
|
||||
except Exception:
|
||||
pass
|
||||
self._protocol_servers.clear()
|
||||
for lease in list(self.lease_manager.all_leases()):
|
||||
self.lease_manager.release(lease.tunnel_id, reason="service_stop")
|
||||
if self._sweeper_task:
|
||||
@@ -561,9 +588,9 @@ class ReverseTunnelService:
|
||||
raise ValueError("unknown_tunnel")
|
||||
bridge = self.ensure_bridge(lease)
|
||||
bridge.attach_operator(operator_id)
|
||||
if lease.domain.lower() == "ps":
|
||||
if (lease.protocol or "").lower() in {"ps", "powershell"}:
|
||||
try:
|
||||
server = self.ensure_ps_server(tunnel_id)
|
||||
server = self.ensure_protocol_server(tunnel_id)
|
||||
if server:
|
||||
server.open_channel()
|
||||
except Exception:
|
||||
@@ -629,6 +656,22 @@ class ReverseTunnelService:
|
||||
lease.expires_at = expires_at
|
||||
return token
|
||||
|
||||
def stop_tunnel(self, tunnel_id: str, *, reason: str = "operator_stop", code: int = CLOSE_AGENT_SHUTDOWN) -> bool:
|
||||
"""Request a graceful stop for a tunnel (operator-driven)."""
|
||||
|
||||
lease = self.lease_manager.get(tunnel_id)
|
||||
if lease is None:
|
||||
return False
|
||||
server = self.get_protocol_server(tunnel_id)
|
||||
if server and hasattr(server, "close"):
|
||||
try:
|
||||
server.close(code=code, reason=reason)
|
||||
except Exception:
|
||||
self.logger.debug("protocol server close failed tunnel_id=%s", tunnel_id, exc_info=True)
|
||||
self._push_stop_to_agent(lease, reason=reason)
|
||||
self.release_bridge(tunnel_id, reason=reason)
|
||||
return True
|
||||
|
||||
def _push_start_to_agent(self, lease: TunnelLease) -> None:
|
||||
"""Notify the target agent about the new lease over Socket.IO (best-effort)."""
|
||||
|
||||
@@ -658,6 +701,26 @@ class ReverseTunnelService:
|
||||
except Exception:
|
||||
self.logger.debug("Failed to emit reverse_tunnel_start for tunnel_id=%s", lease.tunnel_id, exc_info=True)
|
||||
|
||||
def _push_stop_to_agent(self, lease: TunnelLease, *, reason: str = "operator_stop") -> None:
|
||||
"""Notify the agent to tear down a tunnel (best-effort)."""
|
||||
|
||||
if not self._socketio:
|
||||
return
|
||||
try:
|
||||
self._socketio.emit(
|
||||
"reverse_tunnel_stop",
|
||||
{"tunnel_id": lease.tunnel_id, "reason": reason},
|
||||
namespace="/",
|
||||
)
|
||||
self.audit_logger.info(
|
||||
"lease_push_stop tunnel_id=%s agent_id=%s reason=%s",
|
||||
lease.tunnel_id,
|
||||
lease.agent_id,
|
||||
reason or "-",
|
||||
)
|
||||
except Exception:
|
||||
self.logger.debug("Failed to emit reverse_tunnel_stop for tunnel_id=%s", lease.tunnel_id, exc_info=True)
|
||||
|
||||
def lease_summary(self, lease: TunnelLease) -> Dict[str, object]:
|
||||
return {
|
||||
"tunnel_id": lease.tunnel_id,
|
||||
@@ -892,7 +955,7 @@ class ReverseTunnelService:
|
||||
pass
|
||||
|
||||
def _dispatch_agent_frame(self, tunnel_id: str, frame: TunnelFrame) -> None:
|
||||
server = self._ps_servers.get(tunnel_id)
|
||||
server = self._protocol_servers.get(tunnel_id)
|
||||
if not server:
|
||||
return
|
||||
try:
|
||||
@@ -1136,33 +1199,39 @@ class ReverseTunnelService:
|
||||
self._bridges[lease.tunnel_id] = bridge
|
||||
return bridge
|
||||
|
||||
def ensure_ps_server(self, tunnel_id: str) -> Optional[PowershellChannelServer]:
|
||||
server = self._ps_servers.get(tunnel_id)
|
||||
def ensure_protocol_server(self, tunnel_id: str) -> Optional[object]:
|
||||
server = self._protocol_servers.get(tunnel_id)
|
||||
if server:
|
||||
return server
|
||||
lease = self.lease_manager.get(tunnel_id)
|
||||
if lease is None:
|
||||
return None
|
||||
handler_cls = self.protocol_registry.get((lease.protocol or "").lower())
|
||||
if handler_cls is None:
|
||||
return None
|
||||
bridge = self.ensure_bridge(lease)
|
||||
server = PowershellChannelServer(
|
||||
bridge=bridge,
|
||||
service=self,
|
||||
frame_cls=TunnelFrame,
|
||||
close_frame_fn=close_frame,
|
||||
)
|
||||
self._ps_servers[tunnel_id] = server
|
||||
try:
|
||||
server = handler_cls(
|
||||
bridge=bridge,
|
||||
service=self,
|
||||
frame_cls=TunnelFrame,
|
||||
close_frame_fn=close_frame,
|
||||
)
|
||||
except TypeError:
|
||||
server = handler_cls(bridge=bridge, service=self)
|
||||
self._protocol_servers[tunnel_id] = server
|
||||
return server
|
||||
|
||||
def get_ps_server(self, tunnel_id: str) -> Optional[PowershellChannelServer]:
|
||||
return self._ps_servers.get(tunnel_id)
|
||||
def get_protocol_server(self, tunnel_id: str) -> Optional[object]:
|
||||
return self._protocol_servers.get(tunnel_id)
|
||||
|
||||
def release_bridge(self, tunnel_id: str, *, reason: str = "bridge_released") -> None:
|
||||
bridge = self._bridges.pop(tunnel_id, None)
|
||||
if bridge:
|
||||
bridge.stop(reason=reason)
|
||||
if tunnel_id in self._ps_servers:
|
||||
if tunnel_id in self._protocol_servers:
|
||||
try:
|
||||
self._ps_servers.pop(tunnel_id, None)
|
||||
self._protocol_servers.pop(tunnel_id, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -20,7 +20,7 @@ from flask_socketio import SocketIO
|
||||
|
||||
from ...database import initialise_engine_database
|
||||
from ...server import EngineContext
|
||||
from .Agent.ReverseTunnel import (
|
||||
from .Agent.reverse_tunnel_orchestrator import (
|
||||
ReverseTunnelService,
|
||||
TunnelBridge,
|
||||
decode_frame,
|
||||
@@ -406,8 +406,8 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
|
||||
tunnel_id = _operator_sessions.get(sid)
|
||||
if not tunnel_id:
|
||||
return None, None, {"error": "not_joined"}
|
||||
server = tunnel_service.ensure_ps_server(tunnel_id)
|
||||
if server is None:
|
||||
server = tunnel_service.ensure_protocol_server(tunnel_id)
|
||||
if server is None or not hasattr(server, "open_channel"):
|
||||
return None, tunnel_id, {"error": "ps_unsupported"}
|
||||
return server, tunnel_id, None
|
||||
|
||||
@@ -489,4 +489,9 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
|
||||
@socket_server.on("disconnect", namespace=tunnel_namespace)
|
||||
def _ws_tunnel_disconnect():
|
||||
sid = request.sid
|
||||
_operator_sessions.pop(sid, None)
|
||||
tunnel_id = _operator_sessions.pop(sid, None)
|
||||
if tunnel_id and tunnel_id not in _operator_sessions.values():
|
||||
try:
|
||||
tunnel_service.stop_tunnel(tunnel_id, reason="operator_socket_disconnect")
|
||||
except Exception as exc:
|
||||
logger.debug("ws_tunnel_disconnect stop_tunnel failed tunnel_id=%s: %s", tunnel_id, exc, exc_info=True)
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
const terminalRef = useRef(null);
|
||||
const joinRetryRef = useRef(null);
|
||||
const joinAttemptsRef = useRef(0);
|
||||
const DOMAIN_REMOTE_SHELL = "remote-interactive-shell";
|
||||
|
||||
const hostname = useMemo(() => {
|
||||
return (
|
||||
@@ -164,6 +165,23 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
setPolling(false);
|
||||
}, []);
|
||||
|
||||
const stopTunnel = useCallback(
|
||||
async (reason = "operator_disconnect") => {
|
||||
const tunnelId = tunnel?.tunnel_id;
|
||||
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
|
||||
}
|
||||
},
|
||||
[tunnel?.tunnel_id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
@@ -172,8 +190,9 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
clearTimeout(joinRetryRef.current);
|
||||
joinRetryRef.current = null;
|
||||
}
|
||||
stopTunnel("component_unmount");
|
||||
};
|
||||
}, [disconnectSocket, stopPolling]);
|
||||
}, [disconnectSocket, stopPolling, stopTunnel]);
|
||||
|
||||
const appendOutput = useCallback((text) => {
|
||||
if (!text) return;
|
||||
@@ -270,7 +289,8 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
[appendOutput, emitAsync, stopPolling, disconnectSocket]
|
||||
);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
const handleDisconnect = useCallback(
|
||||
async (reason = "operator_disconnect") => {
|
||||
const socket = socketRef.current;
|
||||
const tunnelId = tunnel?.tunnel_id;
|
||||
if (joinRetryRef.current) {
|
||||
@@ -282,11 +302,14 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
const frame = buildCloseFrame(1, CLOSE_AGENT_SHUTDOWN, "operator_close");
|
||||
socket.emit("send", { frame });
|
||||
}
|
||||
await stopTunnel(reason);
|
||||
stopPolling();
|
||||
disconnectSocket();
|
||||
setTunnel(null);
|
||||
setSessionState("closed");
|
||||
}, [disconnectSocket, stopPolling, tunnel?.tunnel_id]);
|
||||
},
|
||||
[disconnectSocket, stopPolling, stopTunnel, tunnel?.tunnel_id]
|
||||
);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (!socketRef.current || sessionState === "idle") return;
|
||||
@@ -410,7 +433,7 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
const resp = await fetch("/api/tunnel/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agent_id: agentId, protocol: "ps", domain: "ps" }),
|
||||
body: JSON.stringify({ agent_id: agentId, protocol: "ps", domain: DOMAIN_REMOTE_SHELL }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
@@ -429,7 +452,7 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
setStatusSeverity("error");
|
||||
setStatusMessage("");
|
||||
}
|
||||
}, [agentId, connectSocket, connectionType, resetState]);
|
||||
}, [DOMAIN_REMOTE_SHELL, agentId, connectSocket, connectionType, resetState]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text) => {
|
||||
@@ -456,6 +479,17 @@ export default function ReverseTunnelPowershell({ device }) {
|
||||
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 = [
|
||||
{
|
||||
label: isConnected ? "Connected" : isClosed ? "Session ended" : sessionState === "idle" ? "Idle" : sessionState.replace(/_/g, " "),
|
||||
|
||||
Reference in New Issue
Block a user