diff --git a/Data/Agent/Roles/role_VpnShell.py b/Data/Agent/Roles/role_RemotePowershell.py similarity index 84% rename from Data/Agent/Roles/role_VpnShell.py rename to Data/Agent/Roles/role_RemotePowershell.py index 7aa57fc9..ca0136d5 100644 --- a/Data/Agent/Roles/role_VpnShell.py +++ b/Data/Agent/Roles/role_RemotePowershell.py @@ -1,5 +1,5 @@ # ====================================================== -# Data\Agent\Roles\role_VpnShell.py +# Data\Agent\Roles\role_RemotePowershell.py # Description: PowerShell TCP server for VPN shell access (Engine connects over WireGuard /32). # # API Endpoints (if applicable): None @@ -19,19 +19,19 @@ from pathlib import Path from typing import Any, Optional import os -ROLE_NAME = "VpnShell" +ROLE_NAME = "RemotePowershell" ROLE_CONTEXTS = ["system"] -FIREWALL_RULE_NAME = "Borealis - WireGuard - Shell" -FIREWALL_REMOTE_ADDRESS = "10.255.0.1/32" def _log_path() -> Path: + # Keep shell logs alongside other VPN tunnel artifacts. root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel" root.mkdir(parents=True, exist_ok=True) return root / "remote_shell.log" def _write_log(message: str) -> None: + # Lightweight file logger for the shell bridge; avoid raising on failures. ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) try: _log_path().open("a", encoding="utf-8").write(f"[{ts}] [vpn-shell] {message}\n") @@ -40,13 +40,16 @@ def _write_log(message: str) -> None: def _b64encode(data: bytes) -> str: + # Wire payloads are JSON lines; encode binary stdout safely. return base64.b64encode(data).decode("ascii").strip() def _b64decode(value: str) -> bytes: + # Decode base64-encoded stdin payloads from the engine. return base64.b64decode(value.encode("ascii")) def _resolve_shell_port() -> int: + # Use the configured port when present, otherwise default to 47002. raw = os.environ.get("BOREALIS_WIREGUARD_SHELL_PORT") try: value = int(raw) if raw is not None else 47002 @@ -57,30 +60,6 @@ def _resolve_shell_port() -> int: return value -def _ensure_firewall_rule(port: int) -> None: - if os.name != "nt": - return - rule_name = FIREWALL_RULE_NAME.replace("'", "''") - command = ( - "Remove-NetFirewallRule -DisplayName '{name}' -ErrorAction SilentlyContinue; " - "New-NetFirewallRule -DisplayName '{name}' -Direction Inbound -Action Allow " - "-Protocol TCP -LocalPort {port} -RemoteAddress {remote} -Profile Any" - ).format(name=rule_name, port=port, remote=FIREWALL_REMOTE_ADDRESS) - try: - result = subprocess.run( - ["powershell.exe", "-NoProfile", "-Command", command], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - _write_log(f"Failed to ensure firewall rule for VPN shell: {result.stderr.strip()}") - else: - _write_log(f"Ensured firewall rule for VPN shell on port {port}.") - except Exception as exc: - _write_log(f"Failed to ensure firewall rule for VPN shell: {exc}") - - class ShellSession: def __init__(self, conn: socket.socket, address: tuple[str, int]) -> None: self.conn = conn @@ -93,6 +72,7 @@ class ShellSession: self.output_bytes = 0 def start(self) -> None: + # Spawn an interactive PowerShell process and bridge stdin/stdout. _write_log(f"Shell session starting for {self.address[0]}:{self.address[1]}") self.proc = subprocess.Popen( ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"], @@ -106,6 +86,7 @@ class ShellSession: self._writer_loop() def _reader_loop(self) -> None: + # Forward PowerShell stdout to the engine as JSONL payloads. if not self.proc or not self.proc.stdout: return try: @@ -126,6 +107,7 @@ class ShellSession: _write_log(f"Shell stdout error: {exc}") def _writer_loop(self) -> None: + # Read JSONL stdin from the engine and feed it into PowerShell. buffer = b"" try: while not self._stop.is_set(): @@ -165,6 +147,7 @@ class ShellSession: self.close() def close(self) -> None: + # Ensure the TCP connection and PowerShell child are cleaned up. self._stop.set() try: self.conn.close() @@ -189,12 +172,12 @@ class ShellServer: def __init__(self, host: str = "0.0.0.0", port: Optional[int] = None) -> None: self.host = host self.port = port or _resolve_shell_port() - _ensure_firewall_rule(self.port) self._thread = threading.Thread(target=self._serve, daemon=True) self._thread.start() _write_log(f"VPN shell server listening on {self.host}:{self.port}") def _serve(self) -> None: + # Accept TCP shell connections; restrict to the WireGuard subnet. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((self.host, self.port)) @@ -213,6 +196,7 @@ class ShellServer: class Role: def __init__(self, ctx) -> None: + # Start the shell server immediately when the role loads. self.ctx = ctx self.server = ShellServer() diff --git a/Data/Agent/Roles/role_WireGuardTunnel.py b/Data/Agent/Roles/role_WireGuardTunnel.py index 76cfdf5d..26177a65 100644 --- a/Data/Agent/Roles/role_WireGuardTunnel.py +++ b/Data/Agent/Roles/role_WireGuardTunnel.py @@ -52,6 +52,7 @@ TUNNEL_NAME = "Borealis" TUNNEL_DISPLAY_NAME = "Borealis" SERVICE_DISPLAY_NAME = "Borealis - WireGuard - Agent" TUNNEL_IDLE_ADDRESS = "169.254.255.254/32" +FIREWALL_RULE_NAME = "Borealis - WireGuard - Shell" def _log_path() -> Path: @@ -108,6 +109,17 @@ def _generate_client_keys(root: Path) -> Dict[str, str]: return {"private": priv, "public": pub} +def _resolve_shell_port() -> int: + raw = os.environ.get("BOREALIS_WIREGUARD_SHELL_PORT") + try: + value = int(raw) if raw is not None else 47002 + except Exception: + value = 47002 + if value < 1 or value > 65535: + return 47002 + return value + + class SessionConfig: def __init__( self, @@ -254,6 +266,63 @@ class WireGuardClient: ] ) + def _normalize_firewall_remote(self, allowed_ips: Optional[str]) -> Optional[str]: + if not allowed_ips: + return None + try: + network = ipaddress.ip_network(str(allowed_ips).strip(), strict=False) + except Exception: + _write_log(f"Refusing to apply shell firewall rule; invalid allowed_ips={allowed_ips}.") + return None + if network.prefixlen != 32: + _write_log(f"Refusing to apply shell firewall rule; allowed_ips not /32: {network}.") + return None + return str(network) + + def _ensure_shell_firewall(self, allowed_ips: Optional[str]) -> None: + if os.name != "nt": + return + remote = self._normalize_firewall_remote(allowed_ips) + if not remote: + return + rule_name = FIREWALL_RULE_NAME.replace("'", "''") + port = _resolve_shell_port() + command = ( + "Remove-NetFirewallRule -DisplayName '{name}' -ErrorAction SilentlyContinue; " + "New-NetFirewallRule -DisplayName '{name}' -Direction Inbound -Action Allow " + "-Protocol TCP -LocalPort {port} -RemoteAddress {remote} -Profile Any" + ).format(name=rule_name, port=port, remote=remote) + try: + result = subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", command], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + _write_log(f"Failed to ensure shell firewall rule: {result.stderr.strip()}") + else: + _write_log(f"Ensured shell firewall rule for {remote} on port {port}.") + except Exception as exc: + _write_log(f"Failed to ensure shell firewall rule: {exc}") + + def _remove_shell_firewall(self) -> None: + if os.name != "nt": + return + rule_name = FIREWALL_RULE_NAME.replace("'", "''") + command = "Remove-NetFirewallRule -DisplayName '{name}' -ErrorAction SilentlyContinue".format( + name=rule_name + ) + try: + subprocess.run( + ["powershell.exe", "-NoProfile", "-Command", command], + capture_output=True, + text=True, + check=False, + ) + except Exception: + pass + def _service_exists(self) -> bool: code, _, _ = self._run(["sc.exe", "query", self._service_id()]) if code == 0: @@ -447,6 +516,7 @@ class WireGuardClient: self._restart_service() self._ensure_adapter_name() self._ensure_service_display_name() + self._ensure_shell_firewall(session.allowed_ips) self.session = session self.idle_deadline = time.time() + max(60, session.idle_seconds) @@ -455,6 +525,7 @@ class WireGuardClient: def stop_session(self, reason: str = "stop", ignore_missing: bool = False) -> None: with self._session_lock: + self._remove_shell_firewall() if not self._service_exists(): if not ignore_missing: _write_log("WireGuard tunnel service not found when stopping session.") diff --git a/Data/Engine/config.py b/Data/Engine/config.py index e7c6d96c..78fa7711 100644 --- a/Data/Engine/config.py +++ b/Data/Engine/config.py @@ -80,7 +80,7 @@ API_LOG_FILE_PATH = LOG_ROOT / "api.log" VPN_TUNNEL_LOG_FILE_PATH = LOG_ROOT / "VPN_Tunnel" / "tunnel.log" DEFAULT_WIREGUARD_PORT = 30000 DEFAULT_WIREGUARD_ENGINE_VIRTUAL_IP = "10.255.0.1/32" -DEFAULT_WIREGUARD_PEER_NETWORK = "10.255.0.0/24" +DEFAULT_WIREGUARD_PEER_NETWORK = "10.255.0.0/16" DEFAULT_WIREGUARD_SHELL_PORT = 47002 DEFAULT_WIREGUARD_ACL_WINDOWS = (3389, 5985, 5986, 5900, 3478, DEFAULT_WIREGUARD_SHELL_PORT) VPN_SERVER_CERT_ROOT = PROJECT_ROOT / "Engine" / "Certificates" / "VPN_Server" diff --git a/Docs/Codex/BOREALIS_AGENT.md b/Docs/Codex/BOREALIS_AGENT.md index 476736df..cd4e25dc 100644 --- a/Docs/Codex/BOREALIS_AGENT.md +++ b/Docs/Codex/BOREALIS_AGENT.md @@ -22,7 +22,7 @@ Use this doc for agent-only work (Borealis agent runtime under `Data/Agent` → ## Reverse VPN Tunnels - WireGuard reverse VPN design and lifecycle live in `Docs/Codex/REVERSE_TUNNELS.md` and `Docs/Codex/Reverse_VPN_Tunnel_Deployment.md`. -- Agent roles: `Data/Agent/Roles/role_WireGuardTunnel.py` (tunnel lifecycle) and `Data/Agent/Roles/role_VpnShell.py` (VPN PowerShell TCP server). +- Agent roles: `Data/Agent/Roles/role_WireGuardTunnel.py` (tunnel lifecycle) and `Data/Agent/Roles/role_RemotePowershell.py` (VPN PowerShell TCP server). ## Execution Contexts & Roles - Auto-discovers roles from `Data/Agent/Roles/`; no loader changes needed. diff --git a/Docs/Codex/REVERSE_TUNNELS.md b/Docs/Codex/REVERSE_TUNNELS.md index 574dfe6d..7e2be086 100644 --- a/Docs/Codex/REVERSE_TUNNELS.md +++ b/Docs/Codex/REVERSE_TUNNELS.md @@ -33,7 +33,7 @@ This document is the reference for Borealis reverse VPN tunnels built on WireGua ## 4) Agent Components - Tunnel lifecycle: `Data/Agent/Roles/role_WireGuardTunnel.py` - Validates orchestration tokens, starts/stops WireGuard client service, enforces idle. -- Shell server: `Data/Agent/Roles/role_VpnShell.py` +- Shell server: `Data/Agent/Roles/role_RemotePowershell.py` - TCP PowerShell server bound to `0.0.0.0:47002`, restricted to VPN subnet (10.255.x.x). - Logging: `Agent/Logs/VPN_Tunnel/tunnel.log` (tunnel lifecycle) and `Agent/Logs/VPN_Tunnel/remote_shell.log` (shell I/O). diff --git a/Docs/Codex/WireGuard_Troubleshooting.md b/Docs/Codex/WireGuard_Troubleshooting.md index 0fc72d0d..84e5bf66 100644 --- a/Docs/Codex/WireGuard_Troubleshooting.md +++ b/Docs/Codex/WireGuard_Troubleshooting.md @@ -50,6 +50,7 @@ You are a new Codex agent working in d:\Github\Borealis. Please do the following - Endpoint override: if Engine sends localhost, use host from server_url.txt and port from the token. - Config path preference: Agent\Borealis\Settings\WireGuard. - Service display name set to "Borealis - WireGuard - Agent". + - Applies/removes the VPN shell firewall rule using the engine /32 from allowed_ips. - Data/Engine/services/VPN/wireguard_server.py - Engine config path: Engine\WireGuard\borealis-wg.conf (project root only). - Removed invalid "SaveConfig = false" line (WireGuard rejected it). @@ -60,19 +61,16 @@ You are a new Codex agent working in d:\Github\Borealis. Please do the following Note: Data/Agent changes only apply after Borealis.ps1 re-stages the agent under Agent\. -## Current Symptoms (2026-01-13 23:40) +## Current Symptoms (2026-01-14 00:05) -- `wg.exe show` confirms the tunnel is up with a recent handshake and RX/TX bytes on both Engine and Agent. -- Engine sees the remote agent peer at 10.0.0.55:59733; agent sees the engine endpoint at 10.0.0.54:30000. -- ICMP over the tunnel works: `Test-NetConnection -ComputerName 10.255.0.2 -Port 47002` reports `PingSucceeded=True` but `TcpTestSucceeded=False`. -- Remote shell connects to 10.255.0.2:47002 still time out; agent logs show the shell server listening but no accepted connections. -- Agent session idles out; the on-disk `Borealis.conf` reverts to idle-only [Interface] after stop (no [Peer]). -- `wireguard.exe /dumplog /tail` fails with "Stdout must be set" when run from PowerShell. +- Tunnel handshakes are healthy; TCP shell connectivity succeeds after adding a firewall rule for TCP/47002 from the engine /32. +- The firewall rule is now applied/removed by `role_WireGuardTunnel.py` using the engine /32 in the `allowed_ips` payload. +- `wireguard.exe /dumplog /tail` still fails with "Stdout must be set" when run from PowerShell (use file redirection). ## Key Paths - Agent WireGuard role: Data/Agent/Roles/role_WireGuardTunnel.py -- Agent VPN shell role: Data/Agent/Roles/role_VpnShell.py +- Agent VPN shell role: Data/Agent/Roles/role_RemotePowershell.py - Engine WireGuard manager: Data/Engine/services/VPN/wireguard_server.py - Engine tunnel service: Data/Engine/services/VPN/vpn_tunnel_service.py - Agent tunnel logs: Z:\Agent\Logs\VPN_Tunnel\tunnel.log @@ -107,8 +105,7 @@ Note: Data/Agent changes only apply after Borealis.ps1 re-stages the agent under ## Current Blockers / Next Steps -1) During an active session, run `Test-NetConnection -ComputerName 10.255.0.2 -Port 47002` on the Engine and confirm it reaches the agent. -2) If the TCP test times out, inspect agent-side firewall rules; the shell server listens but may be blocked on the WireGuard adapter. - - Added a candidate fix in `Data/Agent/Roles/role_VpnShell.py` to add an inbound firewall rule for TCP/47002 from 10.255.0.1/32. +1) Ensure the agent runtime is re-staged so `role_WireGuardTunnel.py` applies the shell firewall rule on tunnel start. +2) During an active session, run `Test-NetConnection -ComputerName 10.255.0.2 -Port 47002` on the Engine and confirm it reaches the agent. 3) While the session is active, confirm `Agent\Borealis\Settings\WireGuard\Borealis.conf` includes a [Peer] with endpoint/AllowedIPs (it reverts to idle config after stop). -4) Capture engine + agent tunnel/shell logs around a failed shell open attempt and re-check WireGuard service state. +4) Capture engine + agent tunnel/shell logs around a failed shell open attempt and re-check WireGuard service state if issues persist.