Additional Changes to VPN Tunneling

This commit is contained in:
2026-01-11 19:02:53 -07:00
parent 6ceb59f717
commit df14a1e26a
18 changed files with 681 additions and 175 deletions

View File

@@ -13,11 +13,79 @@ param(
[string]$EnrollmentCode = '' [string]$EnrollmentCode = ''
) )
# Admin/Elevation helpers for Borealis runtime
function Test-IsAdmin {
try {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$p = New-Object Security.Principal.WindowsPrincipal($id)
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch { return $false }
}
function Request-BorealisElevation {
param(
[string]$ScriptPath,
[hashtable]$BoundParameters,
[string[]]$ExtraArgs
)
if (Test-IsAdmin) { return $true }
Write-Host "" # spacer
Write-Host "Borealis requires Administrator permissions for Engine and Agent tasks." -ForegroundColor Yellow -BackgroundColor Black
Write-Host "Grant elevated permissions now? (Y/N)" -ForegroundColor Yellow -BackgroundColor Black
$resp = Read-Host
if ($resp -notin @('y','Y','yes','YES')) { return $false }
$argTokens = @('-NoProfile','-ExecutionPolicy','Bypass','-File', $ScriptPath)
if ($BoundParameters) {
foreach ($entry in $BoundParameters.GetEnumerator()) {
$key = $entry.Key
$value = $entry.Value
if ($value -is [System.Management.Automation.SwitchParameter]) {
if ($value.IsPresent) { $argTokens += "-$key" }
continue
}
if ($value -is [bool]) {
if ($value) { $argTokens += "-$key" }
continue
}
if ($null -ne $value -and "$value" -ne "") {
$argTokens += "-$key"
$argTokens += "$value"
}
}
}
if ($ExtraArgs) { $argTokens += $ExtraArgs }
$argLine = ($argTokens | ForEach-Object {
$text = [string]$_
if ($text -match '\s') {
'"' + ($text -replace '"','`"') + '"'
} else {
$text
}
}) -join ' '
try {
Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $argLine -WindowStyle Normal | Out-Null
return $false # stop current non-elevated instance
} catch {
Write-Host "Elevation was denied or failed." -ForegroundColor Red
return $false
}
}
# Preselect menu choices from CLI args (optional) # Preselect menu choices from CLI args (optional)
$choice = $null $choice = $null
$modeChoice = $null $modeChoice = $null
$engineModeChoice = $null $engineModeChoice = $null
$scriptPath = $PSCommandPath
if (-not $scriptPath -or $scriptPath -eq '') { $scriptPath = $MyInvocation.MyCommand.Definition }
if (-not (Request-BorealisElevation -ScriptPath $scriptPath -BoundParameters $PSBoundParameters -ExtraArgs $MyInvocation.UnboundArguments)) {
exit 0
}
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
if ($EngineTests) { if ($EngineTests) {
@@ -115,38 +183,51 @@ function Set-FileUtf8Content {
} }
} }
# Admin/Elevation helpers for Agent deployment function Get-LatestWriteTime {
function Test-IsAdmin { param(
[string]$Path
)
try { try {
$id = [Security.Principal.WindowsIdentity]::GetCurrent() $item = Get-ChildItem -Path $Path -Recurse -Force -ErrorAction Stop |
$p = New-Object Security.Principal.WindowsPrincipal($id) Sort-Object -Property LastWriteTime -Descending |
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) Select-Object -First 1
} catch { return $false } if ($item) { return $item.LastWriteTime }
} catch {
return [datetime]::MinValue
}
return [datetime]::MinValue
} }
function Request-AgentElevation { function Sync-EngineRuntime {
param( param(
[string]$ScriptPath, [string]$SourceRoot,
[switch]$Auto [string]$DestinationRoot
) )
if (Test-IsAdmin) { return $true } if (-not (Test-Path $SourceRoot)) { return $false }
if (-not $Auto) { $needsSync = $false
Write-Host "" # spacer if (-not (Test-Path $DestinationRoot)) {
Write-Host "Agent requires Administrator permissions to register scheduled tasks and run reliably." -ForegroundColor Yellow -BackgroundColor Black $needsSync = $true
Write-Host "Grant elevated permissions now? (Y/N)" -ForegroundColor Yellow -BackgroundColor Black } else {
$resp = Read-Host $sourceTime = Get-LatestWriteTime -Path $SourceRoot
if ($resp -notin @('y','Y','yes','YES')) { return $false } $destTime = Get-LatestWriteTime -Path $DestinationRoot
if ($sourceTime -gt $destTime) { $needsSync = $true }
} }
$args = @('-NoProfile','-ExecutionPolicy','Bypass','-File', '"' + $ScriptPath + '"', '-Agent') if (-not $needsSync) { return $false }
try {
Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $args -WindowStyle Normal | Out-Null if (Test-Path $DestinationRoot) {
return $false # stop current non-elevated instance Remove-Item $DestinationRoot -Recurse -Force -ErrorAction SilentlyContinue
} catch {
Write-Host "Elevation was denied or failed." -ForegroundColor Red
return $false
} }
New-Item -Path $DestinationRoot -ItemType Directory -Force | Out-Null
Get-ChildItem -Path $SourceRoot -Force | ForEach-Object {
if ($_.Name -ieq 'Assemblies') {
return
}
Copy-Item -Path $_.FullName -Destination $DestinationRoot -Recurse -Force
}
return $true
} }
# Ensure log directories # Ensure log directories
@@ -1486,12 +1567,6 @@ function InstallOrUpdate-BorealisAgent {
Copy-Item $coreAgentFiles -Destination $agentDestinationFolder -Recurse -Force Copy-Item $coreAgentFiles -Destination $agentDestinationFolder -Recurse -Force
# Ensure ReverseTunnel role is refreshed explicitly (covers incremental changes)
$rtSource = Join-Path $agentSourceRoot 'Roles\ReverseTunnel'
$rtDest = Join-Path $agentDestinationFolder 'Roles'
if (Test-Path $rtSource) {
Copy-Item $rtSource -Destination $rtDest -Recurse -Force
}
} }
. (Join-Path $venvFolderPath 'Scripts\Activate') . (Join-Path $venvFolderPath 'Scripts\Activate')
} }
@@ -1647,6 +1722,7 @@ function InstallOrUpdate-BorealisAgent {
} }
# ---------------------- Main ----------------------- # ---------------------- Main -----------------------
$Host.UI.RawUI.BackgroundColor = 'Black'
Clear-Host Clear-Host
@' @'
::::::::: :::::::: ::::::::: :::::::::: ::: ::: ::::::::::: :::::::: ::::::::: :::::::: ::::::::: :::::::::: ::: ::: ::::::::::: ::::::::
@@ -1731,6 +1807,11 @@ switch ($choice) {
} }
if ($engineImmediateLaunch) { if ($engineImmediateLaunch) {
$engineSourceAbsolute = Join-Path $scriptDir 'Data\Engine'
$engineDataAbsolute = Join-Path $scriptDir 'Engine\Data\Engine'
if (Sync-EngineRuntime -SourceRoot $engineSourceAbsolute -DestinationRoot $engineDataAbsolute) {
Write-Host "Synced Engine runtime code from Data\\Engine." -ForegroundColor DarkCyan
}
Run-Step "Borealis Engine: Launch Flask Server" { Run-Step "Borealis Engine: Launch Flask Server" {
Push-Location (Join-Path $scriptDir "Engine") Push-Location (Join-Path $scriptDir "Engine")
$py = Join-Path $scriptDir "Engine\Scripts\python.exe" $py = Join-Path $scriptDir "Engine\Scripts\python.exe"
@@ -2047,15 +2128,11 @@ switch ($choice) {
"2" { "2" {
$host.UI.RawUI.WindowTitle = "Borealis Agent" $host.UI.RawUI.WindowTitle = "Borealis Agent"
Write-Host " " Write-Host " "
# Ensure elevation before performing Agent deployment if (-not (Test-IsAdmin)) {
$scriptPath = $PSCommandPath Write-Host "Administrator permissions are required to deploy the Borealis Agent." -ForegroundColor Red
if (-not $scriptPath -or $scriptPath -eq '') { $scriptPath = $MyInvocation.MyCommand.Definition } return
# If already elevated, skip prompt; otherwise prompt, then relaunch directly to the Agent deploy flow via -Agent
$cont = Request-AgentElevation -ScriptPath $scriptPath
if (-not $cont -and -not (Test-IsAdmin)) { return }
if (Test-IsAdmin) {
Write-Host "Escalated Permissions Granted > Agent is Eligible for Deployment." -ForegroundColor Green
} }
Write-Host "Escalated Permissions Granted > Agent is Eligible for Deployment." -ForegroundColor Green
Write-Host "Deploying Borealis Agent (fresh install/update path)..." -ForegroundColor Cyan Write-Host "Deploying Borealis Agent (fresh install/update path)..." -ForegroundColor Cyan
InstallOrUpdate-BorealisAgent InstallOrUpdate-BorealisAgent
break break

View File

@@ -47,11 +47,11 @@ def _b64decode(value: str) -> bytes:
def _resolve_shell_port() -> int: def _resolve_shell_port() -> int:
raw = os.environ.get("BOREALIS_WIREGUARD_SHELL_PORT") raw = os.environ.get("BOREALIS_WIREGUARD_SHELL_PORT")
try: try:
value = int(raw) if raw is not None else 47001 value = int(raw) if raw is not None else 47002
except Exception: except Exception:
value = 47001 value = 47002
if value < 1 or value > 65535: if value < 1 or value > 65535:
return 47001 return 47002
return value return value

View File

@@ -22,13 +22,22 @@ import os
import subprocess import subprocess
import threading import threading
import time import time
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import x25519 from cryptography.hazmat.primitives.asymmetric import x25519
from signature_utils import verify_and_store_script_signature
try:
from signature_utils import verify_and_store_script_signature
except Exception: # pragma: no cover - fallback for runtime path issues
import sys
from pathlib import Path as _Path
base_dir = _Path(__file__).resolve().parents[1]
if str(base_dir) not in sys.path:
sys.path.insert(0, str(base_dir))
from signature_utils import verify_and_store_script_signature
ROLE_NAME = "WireGuardTunnel" ROLE_NAME = "WireGuardTunnel"
ROLE_CONTEXTS = ["system"] ROLE_CONTEXTS = ["system"]
@@ -88,18 +97,31 @@ def _generate_client_keys(root: Path) -> Dict[str, str]:
return {"private": priv, "public": pub} return {"private": priv, "public": pub}
@dataclass
class SessionConfig: class SessionConfig:
token: Dict[str, Any] def __init__(
virtual_ip: str self,
allowed_ips: str *,
endpoint: str token: Dict[str, Any],
server_public_key: str virtual_ip: str,
allowed_ports: str allowed_ips: str,
idle_seconds: int = 900 endpoint: str,
preshared_key: Optional[str] = None server_public_key: str,
client_private_key: Optional[str] = None allowed_ports: str,
client_public_key: Optional[str] = None idle_seconds: int = 900,
preshared_key: Optional[str] = None,
client_private_key: Optional[str] = None,
client_public_key: Optional[str] = None,
) -> None:
self.token = token
self.virtual_ip = virtual_ip
self.allowed_ips = allowed_ips
self.endpoint = endpoint
self.server_public_key = server_public_key
self.allowed_ports = allowed_ports
self.idle_seconds = idle_seconds
self.preshared_key = preshared_key
self.client_private_key = client_private_key
self.client_public_key = client_public_key
class WireGuardClient: class WireGuardClient:
@@ -150,6 +172,19 @@ class WireGuardClient:
if port < 1 or port > 65535: if port < 1 or port > 65535:
raise ValueError("Invalid token port") raise ValueError("Invalid token port")
if not signature:
if sig_alg or signing_key:
raise ValueError("Token signature missing")
stored_key = None
if signing_client is not None and hasattr(signing_client, "load_server_signing_key"):
try:
stored_key = signing_client.load_server_signing_key()
except Exception:
stored_key = None
if isinstance(stored_key, str) and stored_key.strip():
raise ValueError("Token signature missing")
return
if signature: if signature:
if sig_alg and str(sig_alg).lower() not in ("ed25519", "eddsa"): if sig_alg and str(sig_alg).lower() not in ("ed25519", "eddsa"):
raise ValueError("Unsupported token signature algorithm") raise ValueError("Unsupported token signature algorithm")
@@ -292,6 +327,11 @@ class Role:
self._log("WireGuard start payload missing/invalid.", error=True) self._log("WireGuard start payload missing/invalid.", error=True)
return None return None
payload_agent_id = payload.get("agent_id") or payload.get("agent_guid")
if payload_agent_id:
if str(payload_agent_id).strip() != str(self.ctx.agent_id).strip():
return None
token = payload.get("token") or payload.get("orchestration_token") token = payload.get("token") or payload.get("orchestration_token")
if not isinstance(token, dict): if not isinstance(token, dict):
self._log("WireGuard start missing token payload.", error=True) self._log("WireGuard start missing token payload.", error=True)
@@ -351,6 +391,9 @@ class Role:
async def _vpn_tunnel_stop(payload): async def _vpn_tunnel_stop(payload):
reason = "server_stop" reason = "server_stop"
if isinstance(payload, dict): if isinstance(payload, dict):
target_agent = payload.get("agent_id")
if target_agent and str(target_agent).strip() != str(self.ctx.agent_id).strip():
return
reason = payload.get("reason") or reason reason = payload.get("reason") or reason
self._log(f"WireGuard stop requested (reason={reason}).") self._log(f"WireGuard stop requested (reason={reason}).")
self.client.stop_session(reason=str(reason)) self.client.stop_session(reason=str(reason))

View File

@@ -1,5 +1,7 @@
import os import os
import importlib.util import importlib.util
import sys
from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -37,8 +39,27 @@ class RoleManager:
self.config = config self.config = config
self.loop = loop self.loop = loop
self.hooks = hooks or {} self.hooks = hooks or {}
self._log_hook = self.hooks.get('log_agent')
self.roles: Dict[str, object] = {} self.roles: Dict[str, object] = {}
# Ensure role helpers alongside Roles/ are importable (e.g., signature_utils.py).
try:
base_path = Path(self.base_dir).resolve()
parent_path = base_path.parent
for candidate in (base_path, parent_path):
if candidate and str(candidate) not in sys.path:
sys.path.insert(0, str(candidate))
except Exception:
pass
def _log(self, message: str, *, error: bool = False) -> None:
if callable(self._log_hook):
try:
target = "agent.error.log" if error else "agent.log"
self._log_hook(message, fname=target)
except Exception:
pass
def _iter_role_files(self) -> List[str]: def _iter_role_files(self) -> List[str]:
roles_dir = os.path.join(self.base_dir, 'Roles') roles_dir = os.path.join(self.base_dir, 'Roles')
if not os.path.isdir(roles_dir): if not os.path.isdir(roles_dir):
@@ -56,7 +77,8 @@ class RoleManager:
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(mod) spec.loader.exec_module(mod)
except Exception: except Exception as exc:
self._log(f"Role load failed during import path={path} error={exc}", error=True)
continue continue
role_name = getattr(mod, 'ROLE_NAME', None) role_name = getattr(mod, 'ROLE_NAME', None)
@@ -75,10 +97,12 @@ class RoleManager:
if hasattr(role_obj, 'register_events'): if hasattr(role_obj, 'register_events'):
try: try:
role_obj.register_events() role_obj.register_events()
except Exception: except Exception as exc:
pass self._log(f"Role register_events failed name={role_name} error={exc}", error=True)
self.roles[role_name] = role_obj self.roles[role_name] = role_obj
except Exception: self._log(f"Role loaded name={role_name} context={self.context}")
except Exception as exc:
self._log(f"Role init failed name={role_name} path={path} error={exc}", error=True)
continue continue
def on_config(self, roles_cfg: List[dict]): def on_config(self, roles_cfg: List[dict]):
@@ -96,4 +120,3 @@ class RoleManager:
role.stop_all() role.stop_all()
except Exception: except Exception:
pass pass

View File

@@ -81,7 +81,7 @@ VPN_TUNNEL_LOG_FILE_PATH = LOG_ROOT / "reverse_tunnel.log"
DEFAULT_WIREGUARD_PORT = 30000 DEFAULT_WIREGUARD_PORT = 30000
DEFAULT_WIREGUARD_ENGINE_VIRTUAL_IP = "10.255.0.1/32" 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/24"
DEFAULT_WIREGUARD_SHELL_PORT = 47001 DEFAULT_WIREGUARD_SHELL_PORT = 47002
DEFAULT_WIREGUARD_ACL_WINDOWS = (3389, 5985, 5986, 5900, 3478, DEFAULT_WIREGUARD_SHELL_PORT) DEFAULT_WIREGUARD_ACL_WINDOWS = (3389, 5985, 5986, 5900, 3478, DEFAULT_WIREGUARD_SHELL_PORT)
VPN_SERVER_CERT_ROOT = PROJECT_ROOT / "Engine" / "Certificates" / "VPN_Server" VPN_SERVER_CERT_ROOT = PROJECT_ROOT / "Engine" / "Certificates" / "VPN_Server"

View File

@@ -117,7 +117,7 @@ def _rotate_daily(path: Path) -> None:
pass pass
_QUIET_SERVICE_LOGS = {"scheduled_jobs"} _QUIET_SERVICE_LOGS = {"scheduled_jobs", "device_enrollment"}
def _make_service_logger(base: Path, logger: logging.Logger) -> Callable[[str, str, Optional[str]], None]: def _make_service_logger(base: Path, logger: logging.Logger) -> Callable[[str, str, Optional[str]], None]:

View File

@@ -12,12 +12,14 @@
from __future__ import annotations from __future__ import annotations
import os import os
from urllib.parse import urlsplit
from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from flask import Blueprint, jsonify, request, session from flask import Blueprint, jsonify, request, session
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from ...VPN import VpnTunnelService from ...VPN import WireGuardServerConfig, WireGuardServerManager, VpnTunnelService
if False: # pragma: no cover - import cycle hint for type checkers if False: # pragma: no cover - import cycle hint for type checkers
from .. import EngineServiceAdapters from .. import EngineServiceAdapters
@@ -63,7 +65,22 @@ def _get_tunnel_service(adapters: "EngineServiceAdapters") -> VpnTunnelService:
if service is None: if service is None:
manager = getattr(adapters.context, "wireguard_server_manager", None) manager = getattr(adapters.context, "wireguard_server_manager", None)
if manager is None: if manager is None:
raise RuntimeError("wireguard_manager_unavailable") try:
manager = WireGuardServerManager(
WireGuardServerConfig(
port=adapters.context.wireguard_port,
engine_virtual_ip=adapters.context.wireguard_engine_virtual_ip,
peer_network=adapters.context.wireguard_peer_network,
private_key_path=Path(adapters.context.wireguard_server_private_key_path),
public_key_path=Path(adapters.context.wireguard_server_public_key_path),
acl_allowlist_windows=tuple(adapters.context.wireguard_acl_allowlist_windows),
log_path=Path(adapters.context.vpn_tunnel_log_path),
)
)
adapters.context.wireguard_server_manager = manager
except Exception as exc:
adapters.context.logger.error("Failed to initialize WireGuard server manager on demand.", exc_info=True)
raise RuntimeError("wireguard_manager_unavailable") from exc
service = VpnTunnelService( service = VpnTunnelService(
context=adapters.context, context=adapters.context,
wireguard_manager=manager, wireguard_manager=manager,
@@ -86,6 +103,20 @@ def _normalize_text(value: Any) -> str:
return "" return ""
def _infer_endpoint_host(req) -> str:
forwarded = (req.headers.get("X-Forwarded-Host") or req.headers.get("X-Original-Host") or "").strip()
host = forwarded.split(",")[0].strip() if forwarded else (req.host or "").strip()
if not host:
return ""
try:
parsed = urlsplit(f"//{host}")
if parsed.hostname:
return parsed.hostname
except Exception:
return host
return host
def register_tunnel(app, adapters: "EngineServiceAdapters") -> None: def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
blueprint = Blueprint("vpn_tunnel", __name__) blueprint = Blueprint("vpn_tunnel", __name__)
logger = adapters.context.logger.getChild("vpn_tunnel.api") logger = adapters.context.logger.getChild("vpn_tunnel.api")
@@ -107,10 +138,15 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
try: try:
tunnel_service = _get_tunnel_service(adapters) tunnel_service = _get_tunnel_service(adapters)
payload = tunnel_service.connect(agent_id=agent_id, operator_id=operator_id) endpoint_host = _infer_endpoint_host(request)
except RuntimeError as exc: payload = tunnel_service.connect(
agent_id=agent_id,
operator_id=operator_id,
endpoint_host=endpoint_host,
)
except Exception as exc:
logger.warning("vpn connect failed for agent_id=%s: %s", agent_id, exc) logger.warning("vpn connect failed for agent_id=%s: %s", agent_id, exc)
return jsonify({"error": "connect_failed"}), 500 return jsonify({"error": "connect_failed", "detail": str(exc)}), 500
return jsonify(payload), 200 return jsonify(payload), 200

View File

@@ -73,6 +73,9 @@ def register(
except Exception: except Exception:
return "" return ""
def _enrollment_log(message: str, context_hint: Optional[str] = None) -> None:
log("device_enrollment", message, context_hint)
def _rate_limited( def _rate_limited(
key: str, key: str,
limiter: SlidingWindowRateLimiter, limiter: SlidingWindowRateLimiter,
@@ -82,8 +85,7 @@ def register(
): ):
decision = limiter.check(key, limit, window_s) decision = limiter.check(key, limit, window_s)
if not decision.allowed: if not decision.allowed:
log( _enrollment_log(
"server",
f"enrollment rate limited key={key} limit={limit}/{window_s}s retry_after={decision.retry_after:.2f}", f"enrollment rate limited key={key} limit={limit}/{window_s}s retry_after={decision.retry_after:.2f}",
context_hint, context_hint,
) )
@@ -93,6 +95,9 @@ def register(
return response return response
return None return None
def _poll_log(message: str, context_hint: Optional[str] = None) -> None:
_enrollment_log(message, context_hint)
def _load_install_code(cur: sqlite3.Cursor, code_value: str) -> Optional[Dict[str, Any]]: def _load_install_code(cur: sqlite3.Cursor, code_value: str) -> Optional[Dict[str, Any]]:
cur.execute( cur.execute(
""" """
@@ -347,8 +352,7 @@ def register(
agent_pubkey_b64 = payload.get("agent_pubkey") agent_pubkey_b64 = payload.get("agent_pubkey")
client_nonce_b64 = payload.get("client_nonce") client_nonce_b64 = payload.get("client_nonce")
log( _enrollment_log(
"server",
"enrollment request received " "enrollment request received "
f"ip={remote} hostname={hostname or '<missing>'} code_mask={_mask_code(enrollment_code)} " f"ip={remote} hostname={hostname or '<missing>'} code_mask={_mask_code(enrollment_code)} "
f"pubkey_len={len(agent_pubkey_b64 or '')} nonce_len={len(client_nonce_b64 or '')}", f"pubkey_len={len(agent_pubkey_b64 or '')} nonce_len={len(client_nonce_b64 or '')}",
@@ -356,35 +360,35 @@ def register(
) )
if not hostname: if not hostname:
log("server", f"enrollment rejected missing_hostname ip={remote}", context_hint) _enrollment_log(f"enrollment rejected missing_hostname ip={remote}", context_hint)
return jsonify({"error": "hostname_required"}), 400 return jsonify({"error": "hostname_required"}), 400
if not enrollment_code: if not enrollment_code:
log("server", f"enrollment rejected missing_code ip={remote} host={hostname}", context_hint) _enrollment_log(f"enrollment rejected missing_code ip={remote} host={hostname}", context_hint)
return jsonify({"error": "enrollment_code_required"}), 400 return jsonify({"error": "enrollment_code_required"}), 400
if not isinstance(agent_pubkey_b64, str): if not isinstance(agent_pubkey_b64, str):
log("server", f"enrollment rejected missing_pubkey ip={remote} host={hostname}", context_hint) _enrollment_log(f"enrollment rejected missing_pubkey ip={remote} host={hostname}", context_hint)
return jsonify({"error": "agent_pubkey_required"}), 400 return jsonify({"error": "agent_pubkey_required"}), 400
if not isinstance(client_nonce_b64, str): if not isinstance(client_nonce_b64, str):
log("server", f"enrollment rejected missing_nonce ip={remote} host={hostname}", context_hint) _enrollment_log(f"enrollment rejected missing_nonce ip={remote} host={hostname}", context_hint)
return jsonify({"error": "client_nonce_required"}), 400 return jsonify({"error": "client_nonce_required"}), 400
try: try:
agent_pubkey_der = crypto_keys.spki_der_from_base64(agent_pubkey_b64) agent_pubkey_der = crypto_keys.spki_der_from_base64(agent_pubkey_b64)
except Exception: except Exception:
log("server", f"enrollment rejected invalid_pubkey ip={remote} host={hostname}", context_hint) _enrollment_log(f"enrollment rejected invalid_pubkey ip={remote} host={hostname}", context_hint)
return jsonify({"error": "invalid_agent_pubkey"}), 400 return jsonify({"error": "invalid_agent_pubkey"}), 400
if len(agent_pubkey_der) < 10: if len(agent_pubkey_der) < 10:
log("server", f"enrollment rejected short_pubkey ip={remote} host={hostname}", context_hint) _enrollment_log(f"enrollment rejected short_pubkey ip={remote} host={hostname}", context_hint)
return jsonify({"error": "invalid_agent_pubkey"}), 400 return jsonify({"error": "invalid_agent_pubkey"}), 400
try: try:
client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True) client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True)
except Exception: except Exception:
log("server", f"enrollment rejected invalid_nonce ip={remote} host={hostname}", context_hint) _enrollment_log(f"enrollment rejected invalid_nonce ip={remote} host={hostname}", context_hint)
return jsonify({"error": "invalid_client_nonce"}), 400 return jsonify({"error": "invalid_client_nonce"}), 400
if len(client_nonce_bytes) < 16: if len(client_nonce_bytes) < 16:
log("server", f"enrollment rejected short_nonce ip={remote} host={hostname}", context_hint) _enrollment_log(f"enrollment rejected short_nonce ip={remote} host={hostname}", context_hint)
return jsonify({"error": "invalid_client_nonce"}), 400 return jsonify({"error": "invalid_client_nonce"}), 400
fingerprint = crypto_keys.fingerprint_from_spki_der(agent_pubkey_der) fingerprint = crypto_keys.fingerprint_from_spki_der(agent_pubkey_der)
@@ -398,8 +402,7 @@ def register(
install_code = _load_install_code(cur, enrollment_code) install_code = _load_install_code(cur, enrollment_code)
site_id = install_code.get("site_id") if install_code else None site_id = install_code.get("site_id") if install_code else None
if site_id is None: if site_id is None:
log( _enrollment_log(
"server",
"enrollment request rejected missing_site_binding " "enrollment request rejected missing_site_binding "
f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}", f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}",
context_hint, context_hint,
@@ -407,8 +410,7 @@ def register(
return jsonify({"error": "invalid_enrollment_code"}), 400 return jsonify({"error": "invalid_enrollment_code"}), 400
cur.execute("SELECT 1 FROM sites WHERE id = ?", (site_id,)) cur.execute("SELECT 1 FROM sites WHERE id = ?", (site_id,))
if cur.fetchone() is None: if cur.fetchone() is None:
log( _enrollment_log(
"server",
"enrollment request rejected missing_site_owner " "enrollment request rejected missing_site_owner "
f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}", f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}",
context_hint, context_hint,
@@ -416,8 +418,7 @@ def register(
return jsonify({"error": "invalid_enrollment_code"}), 400 return jsonify({"error": "invalid_enrollment_code"}), 400
valid_code, reuse_guid = _install_code_valid(install_code, fingerprint, cur) valid_code, reuse_guid = _install_code_valid(install_code, fingerprint, cur)
if not valid_code: if not valid_code:
log( _enrollment_log(
"server",
"enrollment request invalid_code " "enrollment request invalid_code "
f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}", f"host={hostname} fingerprint={fingerprint[:12]} code_mask={_mask_code(enrollment_code)}",
context_hint, context_hint,
@@ -509,8 +510,7 @@ def register(
"server_certificate": _load_tls_bundle(tls_bundle_path), "server_certificate": _load_tls_bundle(tls_bundle_path),
"signing_key": _signing_key_b64(), "signing_key": _signing_key_b64(),
} }
log( _enrollment_log(
"server",
f"enrollment request queued fingerprint={fingerprint[:12]} host={hostname} ip={remote}", f"enrollment request queued fingerprint={fingerprint[:12]} host={hostname} ip={remote}",
context_hint, context_hint,
) )
@@ -524,8 +524,7 @@ def register(
proof_sig_b64 = payload.get("proof_sig") proof_sig_b64 = payload.get("proof_sig")
context_hint = _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER)) context_hint = _canonical_context(request.headers.get(AGENT_CONTEXT_HEADER))
log( _poll_log(
"server",
"enrollment poll received " "enrollment poll received "
f"ref={approval_reference} client_nonce_len={len(client_nonce_b64 or '')}" f"ref={approval_reference} client_nonce_len={len(client_nonce_b64 or '')}"
f" proof_sig_len={len(proof_sig_b64 or '')}", f" proof_sig_len={len(proof_sig_b64 or '')}",
@@ -533,25 +532,25 @@ def register(
) )
if not isinstance(approval_reference, str) or not approval_reference: if not isinstance(approval_reference, str) or not approval_reference:
log("server", "enrollment poll rejected missing_reference", context_hint) _poll_log("enrollment poll rejected missing_reference", context_hint)
return jsonify({"error": "approval_reference_required"}), 400 return jsonify({"error": "approval_reference_required"}), 400
if not isinstance(client_nonce_b64, str): if not isinstance(client_nonce_b64, str):
log("server", f"enrollment poll rejected missing_nonce ref={approval_reference}", context_hint) _poll_log(f"enrollment poll rejected missing_nonce ref={approval_reference}", context_hint)
return jsonify({"error": "client_nonce_required"}), 400 return jsonify({"error": "client_nonce_required"}), 400
if not isinstance(proof_sig_b64, str): if not isinstance(proof_sig_b64, str):
log("server", f"enrollment poll rejected missing_sig ref={approval_reference}", context_hint) _poll_log(f"enrollment poll rejected missing_sig ref={approval_reference}", context_hint)
return jsonify({"error": "proof_sig_required"}), 400 return jsonify({"error": "proof_sig_required"}), 400
try: try:
client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True) client_nonce_bytes = base64.b64decode(client_nonce_b64, validate=True)
except Exception: except Exception:
log("server", f"enrollment poll invalid_client_nonce ref={approval_reference}", context_hint) _poll_log(f"enrollment poll invalid_client_nonce ref={approval_reference}", context_hint)
return jsonify({"error": "invalid_client_nonce"}), 400 return jsonify({"error": "invalid_client_nonce"}), 400
try: try:
proof_sig = base64.b64decode(proof_sig_b64, validate=True) proof_sig = base64.b64decode(proof_sig_b64, validate=True)
except Exception: except Exception:
log("server", f"enrollment poll invalid_sig ref={approval_reference}", context_hint) _poll_log(f"enrollment poll invalid_sig ref={approval_reference}", context_hint)
return jsonify({"error": "invalid_proof_sig"}), 400 return jsonify({"error": "invalid_proof_sig"}), 400
conn = db_conn_factory() conn = db_conn_factory()
@@ -569,7 +568,7 @@ def register(
) )
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
log("server", f"enrollment poll unknown_reference ref={approval_reference}", context_hint) _poll_log(f"enrollment poll unknown_reference ref={approval_reference}", context_hint)
return jsonify({"status": "unknown"}), 404 return jsonify({"status": "unknown"}), 404
( (
@@ -589,13 +588,13 @@ def register(
) = row ) = row
if client_nonce_stored != client_nonce_b64: if client_nonce_stored != client_nonce_b64:
log("server", f"enrollment poll nonce_mismatch ref={approval_reference}", context_hint) _poll_log(f"enrollment poll nonce_mismatch ref={approval_reference}", context_hint)
return jsonify({"error": "nonce_mismatch"}), 400 return jsonify({"error": "nonce_mismatch"}), 400
try: try:
server_nonce_bytes = base64.b64decode(server_nonce_b64, validate=True) server_nonce_bytes = base64.b64decode(server_nonce_b64, validate=True)
except Exception: except Exception:
log("server", f"enrollment poll invalid_server_nonce ref={approval_reference}", context_hint) _poll_log(f"enrollment poll invalid_server_nonce ref={approval_reference}", context_hint)
return jsonify({"error": "server_nonce_invalid"}), 400 return jsonify({"error": "server_nonce_invalid"}), 400
message = server_nonce_bytes + approval_reference.encode("utf-8") + client_nonce_bytes message = server_nonce_bytes + approval_reference.encode("utf-8") + client_nonce_bytes
@@ -603,52 +602,47 @@ def register(
try: try:
public_key = serialization.load_der_public_key(agent_pubkey_der) public_key = serialization.load_der_public_key(agent_pubkey_der)
except Exception: except Exception:
log("server", f"enrollment poll pubkey_load_failed ref={approval_reference}", context_hint) _poll_log(f"enrollment poll pubkey_load_failed ref={approval_reference}", context_hint)
public_key = None public_key = None
if public_key is None: if public_key is None:
log("server", f"enrollment poll invalid_pubkey ref={approval_reference}", context_hint) _poll_log(f"enrollment poll invalid_pubkey ref={approval_reference}", context_hint)
return jsonify({"error": "agent_pubkey_invalid"}), 400 return jsonify({"error": "agent_pubkey_invalid"}), 400
try: try:
public_key.verify(proof_sig, message) public_key.verify(proof_sig, message)
except Exception: except Exception:
log("server", f"enrollment poll invalid_proof ref={approval_reference}", context_hint) _poll_log(f"enrollment poll invalid_proof ref={approval_reference}", context_hint)
return jsonify({"error": "invalid_proof"}), 400 return jsonify({"error": "invalid_proof"}), 400
if status == "pending": if status == "pending":
log( _poll_log(
"server",
f"enrollment poll pending ref={approval_reference} host={hostname_claimed}" f"enrollment poll pending ref={approval_reference} host={hostname_claimed}"
f" fingerprint={fingerprint[:12]}", f" fingerprint={fingerprint[:12]}",
context_hint, context_hint,
) )
return jsonify({"status": "pending", "poll_after_ms": 5000}) return jsonify({"status": "pending", "poll_after_ms": 5000})
if status == "denied": if status == "denied":
log( _poll_log(
"server",
f"enrollment poll denied ref={approval_reference} host={hostname_claimed}", f"enrollment poll denied ref={approval_reference} host={hostname_claimed}",
context_hint, context_hint,
) )
return jsonify({"status": "denied", "reason": "operator_denied"}) return jsonify({"status": "denied", "reason": "operator_denied"})
if status == "expired": if status == "expired":
log( _poll_log(
"server",
f"enrollment poll expired ref={approval_reference} host={hostname_claimed}", f"enrollment poll expired ref={approval_reference} host={hostname_claimed}",
context_hint, context_hint,
) )
return jsonify({"status": "expired"}) return jsonify({"status": "expired"})
if status == "completed": if status == "completed":
log( _poll_log(
"server",
f"enrollment poll already_completed ref={approval_reference} host={hostname_claimed}", f"enrollment poll already_completed ref={approval_reference} host={hostname_claimed}",
context_hint, context_hint,
) )
return jsonify({"status": "approved", "detail": "finalized"}) return jsonify({"status": "approved", "detail": "finalized"})
if status != "approved": if status != "approved":
log( _poll_log(
"server",
f"enrollment poll unexpected_status={status} ref={approval_reference}", f"enrollment poll unexpected_status={status} ref={approval_reference}",
context_hint, context_hint,
) )
@@ -656,8 +650,7 @@ def register(
nonce_key = f"{approval_reference}:{base64.b64encode(proof_sig).decode('ascii')}" nonce_key = f"{approval_reference}:{base64.b64encode(proof_sig).decode('ascii')}"
if not nonce_cache.consume(nonce_key): if not nonce_cache.consume(nonce_key):
log( _poll_log(
"server",
f"enrollment poll replay_detected ref={approval_reference} fingerprint={fingerprint[:12]}", f"enrollment poll replay_detected ref={approval_reference} fingerprint={fingerprint[:12]}",
context_hint, context_hint,
) )
@@ -769,8 +762,7 @@ def register(
finally: finally:
conn.close() conn.close()
log( _poll_log(
"server",
f"enrollment finalized guid={effective_guid} fingerprint={fingerprint[:12]} host={hostname_claimed}", f"enrollment finalized guid={effective_guid} fingerprint={fingerprint[:12]} host={hostname_claimed}",
context_hint, context_hint,
) )

View File

@@ -37,6 +37,7 @@ class VpnSession:
firewall_rules: List[str] = field(default_factory=list) firewall_rules: List[str] = field(default_factory=list)
activity_id: Optional[int] = None activity_id: Optional[int] = None
hostname: Optional[str] = None hostname: Optional[str] = None
endpoint_host: Optional[str] = None
class VpnTunnelService: class VpnTunnelService:
@@ -176,6 +177,37 @@ class VpnTunnelService:
self.logger.debug("Failed to sign VPN orchestration token; sending unsigned.", exc_info=True) self.logger.debug("Failed to sign VPN orchestration token; sending unsigned.", exc_info=True)
return token return token
def _ensure_token(self, session: VpnSession, *, now: Optional[float] = None) -> None:
if not session:
return
current = now if now is not None else time.time()
if session.expires_at > current + 30:
return
session.expires_at = current + 300
session.token = self._issue_token(session.agent_id, session.tunnel_id, session.expires_at)
def _normalize_endpoint_host(self, host: Optional[str]) -> Optional[str]:
if not host:
return None
try:
text = str(host).strip()
except Exception:
return None
return text or None
def _format_endpoint_host(self, host: str) -> str:
if ":" in host and not host.startswith("["):
return f"[{host}]"
return host
def _service_log_event(self, message: str, *, level: str = "INFO") -> None:
if not callable(self.service_log):
return
try:
self.service_log("reverse_tunnel", message, level=level)
except Exception:
self.logger.debug("Failed to write reverse_tunnel service log entry", exc_info=True)
def _refresh_listener(self) -> None: def _refresh_listener(self) -> None:
peers: List[Mapping[str, object]] = [] peers: List[Mapping[str, object]] = []
for session in self._sessions_by_agent.values(): for session in self._sessions_by_agent.values():
@@ -192,14 +224,24 @@ class VpnTunnelService:
return return
self.wg.start_listener(peers) self.wg.start_listener(peers)
def connect(self, *, agent_id: str, operator_id: Optional[str]) -> Mapping[str, Any]: def connect(
self,
*,
agent_id: str,
operator_id: Optional[str],
endpoint_host: Optional[str] = None,
) -> Mapping[str, Any]:
now = time.time() now = time.time()
normalized_host = self._normalize_endpoint_host(endpoint_host)
with self._lock: with self._lock:
existing = self._sessions_by_agent.get(agent_id) existing = self._sessions_by_agent.get(agent_id)
if existing: if existing:
if operator_id: if operator_id:
existing.operator_ids.add(operator_id) existing.operator_ids.add(operator_id)
if normalized_host and not existing.endpoint_host:
existing.endpoint_host = normalized_host
existing.last_activity = now existing.last_activity = now
self._ensure_token(existing, now=now)
return self._session_payload(existing) return self._session_payload(existing)
tunnel_id = uuid.uuid4().hex tunnel_id = uuid.uuid4().hex
@@ -220,6 +262,7 @@ class VpnTunnelService:
created_at=now, created_at=now,
expires_at=now + 300, expires_at=now + 300,
last_activity=now, last_activity=now,
endpoint_host=normalized_host,
) )
if operator_id: if operator_id:
session.operator_ids.add(operator_id) session.operator_ids.add(operator_id)
@@ -247,6 +290,17 @@ class VpnTunnelService:
raise raise
payload = self._session_payload(session) payload = self._session_payload(session)
operator_text = ",".join(sorted(filter(None, session.operator_ids))) or "-"
self._service_log_event(
"vpn_tunnel_start agent_id={0} tunnel_id={1} virtual_ip={2} endpoint={3} allowed_ports={4} operators={5}".format(
session.agent_id,
session.tunnel_id,
session.virtual_ip,
payload.get("endpoint", ""),
",".join(str(p) for p in session.allowed_ports),
operator_text,
)
)
self._emit_start(payload) self._emit_start(payload)
self._log_device_activity(session, event="start") self._log_device_activity(session, event="start")
return payload return payload
@@ -258,6 +312,22 @@ class VpnTunnelService:
return None return None
return self._session_payload(session, include_token=False) return self._session_payload(session, include_token=False)
def session_payload(self, agent_id: str, *, include_token: bool = True) -> Optional[Mapping[str, Any]]:
with self._lock:
session = self._sessions_by_agent.get(agent_id)
if not session:
return None
if include_token:
self._ensure_token(session)
return self._session_payload(session, include_token=include_token)
def request_agent_start(self, agent_id: str) -> Optional[Mapping[str, Any]]:
payload = self.session_payload(agent_id, include_token=True)
if not payload:
return None
self._emit_start(payload)
return payload
def bump_activity(self, agent_id: str) -> None: def bump_activity(self, agent_id: str) -> None:
with self._lock: with self._lock:
session = self._sessions_by_agent.get(agent_id) session = self._sessions_by_agent.get(agent_id)
@@ -283,6 +353,15 @@ class VpnTunnelService:
self.logger.debug("Failed to remove firewall rules for agent=%s", agent_id, exc_info=True) self.logger.debug("Failed to remove firewall rules for agent=%s", agent_id, exc_info=True)
self._refresh_listener() self._refresh_listener()
operator_text = ",".join(sorted(filter(None, session.operator_ids))) or "-"
self._service_log_event(
"vpn_tunnel_stop agent_id={0} tunnel_id={1} reason={2} operators={3}".format(
session.agent_id,
session.tunnel_id,
reason,
operator_text,
)
)
self._emit_stop(session, reason) self._emit_stop(session, reason)
self._log_device_activity(session, event="stop", reason=reason) self._log_device_activity(session, event="stop", reason=reason)
return True return True
@@ -297,6 +376,16 @@ class VpnTunnelService:
def _emit_start(self, payload: Mapping[str, Any]) -> None: def _emit_start(self, payload: Mapping[str, Any]) -> None:
if not self.socketio: if not self.socketio:
return return
agent_id = None
if isinstance(payload, Mapping):
agent_id = payload.get("agent_id")
emit_agent = getattr(self.context, "emit_agent_event", None)
if agent_id and callable(emit_agent):
try:
if emit_agent(agent_id, "vpn_tunnel_start", payload):
return
except Exception:
self.logger.debug("emit_agent_event failed for vpn_tunnel_start", exc_info=True)
try: try:
self.socketio.emit("vpn_tunnel_start", payload, namespace="/") self.socketio.emit("vpn_tunnel_start", payload, namespace="/")
except Exception: except Exception:
@@ -305,6 +394,17 @@ class VpnTunnelService:
def _emit_stop(self, session: VpnSession, reason: str) -> None: def _emit_stop(self, session: VpnSession, reason: str) -> None:
if not self.socketio: if not self.socketio:
return return
emit_agent = getattr(self.context, "emit_agent_event", None)
if callable(emit_agent):
try:
if emit_agent(
session.agent_id,
"vpn_tunnel_stop",
{"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason},
):
return
except Exception:
self.logger.debug("emit_agent_event failed for vpn_tunnel_stop", exc_info=True)
try: try:
self.socketio.emit( self.socketio.emit(
"vpn_tunnel_stop", "vpn_tunnel_stop",
@@ -454,13 +554,15 @@ class VpnTunnelService:
pass pass
def _session_payload(self, session: VpnSession, *, include_token: bool = True) -> Mapping[str, Any]: def _session_payload(self, session: VpnSession, *, include_token: bool = True) -> Mapping[str, Any]:
endpoint_host = session.endpoint_host or str(self._engine_ip.ip)
endpoint_host = self._format_endpoint_host(endpoint_host)
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"tunnel_id": session.tunnel_id, "tunnel_id": session.tunnel_id,
"agent_id": session.agent_id, "agent_id": session.agent_id,
"virtual_ip": session.virtual_ip, "virtual_ip": session.virtual_ip,
"engine_virtual_ip": str(self._engine_ip.ip), "engine_virtual_ip": str(self._engine_ip.ip),
"allowed_ips": f"{self._engine_ip.ip}/32", "allowed_ips": f"{self._engine_ip.ip}/32",
"endpoint": f"{self._engine_ip.ip}:{self.context.wireguard_port}", "endpoint": f"{endpoint_host}:{self.context.wireguard_port}",
"server_public_key": self.wg.server_public_key, "server_public_key": self.wg.server_public_key,
"client_public_key": session.client_public_key, "client_public_key": session.client_public_key,
"client_private_key": session.client_private_key, "client_private_key": session.client_private_key,

View File

@@ -18,6 +18,7 @@ from __future__ import annotations
import base64 import base64
import ipaddress import ipaddress
import logging import logging
import os
import subprocess import subprocess
import tempfile import tempfile
import time import time
@@ -72,6 +73,18 @@ class WireGuardServerManager:
self.server_private_key, self.server_public_key = self._ensure_server_keys() self.server_private_key, self.server_public_key = self._ensure_server_keys()
self._service_name = "borealis-wg" self._service_name = "borealis-wg"
self._temp_dir = Path(tempfile.gettempdir()) / "borealis-wg-engine" self._temp_dir = Path(tempfile.gettempdir()) / "borealis-wg-engine"
self._wireguard_exe = self._resolve_wireguard_exe()
def _resolve_wireguard_exe(self) -> str:
candidates = [
str(Path(os.environ.get("ProgramFiles", "C:\\Program Files")) / "WireGuard" / "wireguard.exe"),
str(Path(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)")) / "WireGuard" / "wireguard.exe"),
"wireguard.exe",
]
for candidate in candidates:
if Path(candidate).is_file():
return candidate
return "wireguard.exe"
def _ensure_cert_dir(self) -> None: def _ensure_cert_dir(self) -> None:
try: try:
@@ -316,7 +329,7 @@ class WireGuardServerManager:
# Ensure old service is removed before re-installing. # Ensure old service is removed before re-installing.
self.stop_listener() self.stop_listener()
args = ["wireguard.exe", "/installtunnelservice", str(config_path)] args = [self._wireguard_exe, "/installtunnelservice", str(config_path)]
code, out, err = self._run_command(args) code, out, err = self._run_command(args)
if code != 0: if code != 0:
self.logger.error("Failed to install WireGuard tunnel service code=%s err=%s", code, err) self.logger.error("Failed to install WireGuard tunnel service code=%s err=%s", code, err)
@@ -326,7 +339,7 @@ class WireGuardServerManager:
def stop_listener(self) -> None: def stop_listener(self) -> None:
"""Stop and remove the WireGuard tunnel service.""" """Stop and remove the WireGuard tunnel service."""
args = ["wireguard.exe", "/uninstalltunnelservice", self._service_name] args = [self._wireguard_exe, "/uninstalltunnelservice", self._service_name]
code, out, err = self._run_command(args) code, out, err = self._run_command(args)
if code != 0: if code != 0:
self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err) self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err)

View File

@@ -20,7 +20,7 @@ from flask_socketio import SocketIO
from ...database import initialise_engine_database from ...database import initialise_engine_database
from ...security import signing from ...security import signing
from ...server import EngineContext from ...server import EngineContext
from ..VPN import VpnTunnelService from ..VPN import WireGuardServerConfig, WireGuardServerManager, VpnTunnelService
from .vpn_shell import VpnShellBridge from .vpn_shell import VpnShellBridge
@@ -63,12 +63,53 @@ class EngineRealtimeAdapters:
self.service_log = _make_service_logger(base, self.context.logger) self.service_log = _make_service_logger(base, self.context.logger)
class AgentSocketRegistry:
def __init__(self, socketio: SocketIO, logger) -> None:
self.socketio = socketio
self.logger = logger
self._sid_by_agent: Dict[str, str] = {}
self._agent_by_sid: Dict[str, str] = {}
def register(self, agent_id: str, sid: str) -> None:
if not agent_id or not sid:
return
previous = self._sid_by_agent.get(agent_id)
if previous and previous != sid:
self._agent_by_sid.pop(previous, None)
self._sid_by_agent[agent_id] = sid
self._agent_by_sid[sid] = agent_id
def unregister(self, sid: str) -> Optional[str]:
agent_id = self._agent_by_sid.pop(sid, None)
if agent_id and self._sid_by_agent.get(agent_id) == sid:
self._sid_by_agent.pop(agent_id, None)
return agent_id
def emit(self, agent_id: str, event: str, payload: Any) -> bool:
sid = self._sid_by_agent.get(agent_id)
if not sid:
return False
try:
self.socketio.emit(event, payload, to=sid)
return True
except Exception:
self.logger.debug("Failed to emit %s to agent_id=%s", event, agent_id, exc_info=True)
return False
def register_realtime(socket_server: SocketIO, context: EngineContext) -> None: def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
"""Register Socket.IO event handlers for the Engine runtime.""" """Register Socket.IO event handlers for the Engine runtime."""
adapters = EngineRealtimeAdapters(context) adapters = EngineRealtimeAdapters(context)
logger = context.logger.getChild("realtime.quick_jobs") logger = context.logger.getChild("realtime.quick_jobs")
agent_logger = context.logger.getChild("realtime.agents")
shell_bridge = VpnShellBridge(socket_server, context) shell_bridge = VpnShellBridge(socket_server, context)
agent_registry = AgentSocketRegistry(socket_server, agent_logger)
def _emit_agent_event(agent_id: str, event: str, payload: Any) -> bool:
return agent_registry.emit(agent_id, event, payload)
setattr(context, "emit_agent_event", _emit_agent_event)
def _get_tunnel_service() -> Optional[VpnTunnelService]: def _get_tunnel_service() -> Optional[VpnTunnelService]:
service = getattr(context, "vpn_tunnel_service", None) service = getattr(context, "vpn_tunnel_service", None)
@@ -76,7 +117,22 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
return service return service
manager = getattr(context, "wireguard_server_manager", None) manager = getattr(context, "wireguard_server_manager", None)
if manager is None: if manager is None:
return None try:
manager = WireGuardServerManager(
WireGuardServerConfig(
port=context.wireguard_port,
engine_virtual_ip=context.wireguard_engine_virtual_ip,
peer_network=context.wireguard_peer_network,
private_key_path=Path(context.wireguard_server_private_key_path),
public_key_path=Path(context.wireguard_server_public_key_path),
acl_allowlist_windows=tuple(context.wireguard_acl_allowlist_windows),
log_path=Path(context.vpn_tunnel_log_path),
)
)
setattr(context, "wireguard_server_manager", manager)
except Exception:
context.logger.error("Failed to initialize WireGuard server manager on demand.", exc_info=True)
return None
try: try:
signer = signing.load_signer() signer = signing.load_signer()
except Exception: except Exception:
@@ -275,6 +331,29 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
service.bump_activity(agent_id) service.bump_activity(agent_id)
return {"status": "ok"} return {"status": "ok"}
@socket_server.on("connect_agent")
def _connect_agent(data: Any) -> Dict[str, Any]:
agent_id = ""
service_mode = ""
if isinstance(data, dict):
agent_id = str(data.get("agent_id") or "").strip()
service_mode = str(data.get("service_mode") or "").strip().lower()
elif isinstance(data, str):
agent_id = data.strip()
if not agent_id:
return {"error": "agent_id_required"}
agent_registry.register(agent_id, request.sid)
agent_logger.info("Agent socket registered agent_id=%s service_mode=%s sid=%s", agent_id, service_mode, request.sid)
service = _get_tunnel_service()
if service:
payload = service.session_payload(agent_id, include_token=True)
if payload:
agent_registry.emit(agent_id, "vpn_tunnel_start", payload)
return {"status": "ok"}
@socket_server.on("vpn_shell_send") @socket_server.on("vpn_shell_send")
def _vpn_shell_send(data: Any) -> Dict[str, Any]: def _vpn_shell_send(data: Any) -> Dict[str, Any]:
payload = None payload = None
@@ -288,10 +367,13 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
return {"status": "ok"} return {"status": "ok"}
@socket_server.on("vpn_shell_close") @socket_server.on("vpn_shell_close")
def _vpn_shell_close() -> Dict[str, Any]: def _vpn_shell_close(data: Any = None) -> Dict[str, Any]:
shell_bridge.close(request.sid) shell_bridge.close(request.sid)
return {"status": "ok"} return {"status": "ok"}
@socket_server.on("disconnect") @socket_server.on("disconnect")
def _ws_disconnect() -> None: def _ws_disconnect() -> None:
agent_id = agent_registry.unregister(request.sid)
if agent_id:
agent_logger.info("Agent socket disconnected agent_id=%s sid=%s", agent_id, request.sid)
shell_bridge.close(request.sid) shell_bridge.close(request.sid)

View File

@@ -13,6 +13,7 @@ import base64
import json import json
import socket import socket
import threading import threading
import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@@ -42,7 +43,10 @@ class ShellSession:
buffer = b"" buffer = b""
try: try:
while True: while True:
data = self.tcp.recv(4096) try:
data = self.tcp.recv(4096)
except (socket.timeout, TimeoutError):
break
if not data: if not data:
break break
buffer += data buffer += data
@@ -100,12 +104,28 @@ class VpnShellBridge:
return None return None
host = str(status.get("virtual_ip") or "").split("/")[0] host = str(status.get("virtual_ip") or "").split("/")[0]
port = int(self.context.wireguard_shell_port) port = int(self.context.wireguard_shell_port)
try: tcp = None
tcp = socket.create_connection((host, port), timeout=5) last_error: Optional[Exception] = None
except Exception: for attempt in range(3):
self.logger.debug("Failed to connect vpn shell to %s:%s", host, port, exc_info=True) try:
tcp = socket.create_connection((host, port), timeout=5)
break
except Exception as exc:
last_error = exc
if attempt == 0:
try:
service.request_agent_start(agent_id)
except Exception:
self.logger.debug("Failed to re-emit vpn_tunnel_start for agent=%s", agent_id, exc_info=True)
time.sleep(1)
if tcp is None:
self.logger.warning("Failed to connect vpn shell to %s:%s", host, port, exc_info=last_error)
return None return None
session = ShellSession(sid=sid, agent_id=agent_id, socketio=self.socketio, tcp=tcp) session = ShellSession(sid=sid, agent_id=agent_id, socketio=self.socketio, tcp=tcp)
try:
session.tcp.settimeout(15)
except Exception:
pass
self._sessions[sid] = session self._sessions[sid] = session
session.start_reader() session.start_reader()
return session return session
@@ -124,4 +144,3 @@ class VpnShellBridge:
if not session: if not session:
return return
session.close() session.close()

View File

@@ -7,6 +7,15 @@
"""Service registration hooks for the Borealis Engine runtime.""" """Service registration hooks for the Borealis Engine runtime."""
from . import API, WebSocket, WebUI from __future__ import annotations
import importlib
from typing import Any
__all__ = ["API", "WebSocket", "WebUI"] __all__ = ["API", "WebSocket", "WebUI"]
def __getattr__(name: str) -> Any:
if name in __all__:
return importlib.import_module(f"{__name__}.{name}")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -75,7 +75,7 @@ const SECTION_HEIGHTS = {
}; };
const buildVpnGroups = (shellPort) => { const buildVpnGroups = (shellPort) => {
const normalizedShell = Number(shellPort) || 47001; const normalizedShell = Number(shellPort) || 47002;
return [ return [
{ {
key: "shell", key: "shell",
@@ -335,7 +335,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
const [vpnToggles, setVpnToggles] = useState({}); const [vpnToggles, setVpnToggles] = useState({});
const [vpnCustomPorts, setVpnCustomPorts] = useState([]); const [vpnCustomPorts, setVpnCustomPorts] = useState([]);
const [vpnDefaultPorts, setVpnDefaultPorts] = useState([]); const [vpnDefaultPorts, setVpnDefaultPorts] = useState([]);
const [vpnShellPort, setVpnShellPort] = useState(47001); const [vpnShellPort, setVpnShellPort] = useState(47002);
const [vpnLoadedFor, setVpnLoadedFor] = useState(""); const [vpnLoadedFor, setVpnLoadedFor] = useState("");
// Snapshotted status for the lifetime of this page // Snapshotted status for the lifetime of this page
const [lockedStatus, setLockedStatus] = useState(() => { const [lockedStatus, setLockedStatus] = useState(() => {
@@ -347,6 +347,31 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
const now = Date.now() / 1000; const now = Date.now() / 1000;
return now - tsSec <= 300 ? "Online" : "Offline"; return now - tsSec <= 300 ? "Online" : "Offline";
}); });
const summary = details.summary || {};
const vpnAgentId = useMemo(() => {
return (
meta.agentId ||
summary.agent_id ||
agent?.agent_id ||
agent?.id ||
device?.agent_id ||
device?.agent_guid ||
device?.id ||
""
);
}, [agent?.agent_id, agent?.id, device?.agent_guid, device?.agent_id, device?.id, meta.agentId, summary.agent_id]);
const vpnPortGroups = useMemo(() => buildVpnGroups(vpnShellPort), [vpnShellPort]);
const tunnelDevice = useMemo(
() => ({
...(device || {}),
...(agent || {}),
summary,
hostname: meta.hostname || summary.hostname || device?.hostname || agent?.hostname,
agent_id: meta.agentId || summary.agent_id || agent?.agent_id || agent?.id || device?.agent_id || device?.agent_guid,
agent_guid: meta.agentGuid || summary.agent_guid || device?.agent_guid || device?.guid || agent?.agent_guid || agent?.guid,
}),
[agent, device, meta.agentGuid, meta.agentId, meta.hostname, summary]
);
const quickJobTargets = useMemo(() => { const quickJobTargets = useMemo(() => {
const values = []; const values = [];
const push = (value) => { const push = (value) => {
@@ -715,7 +740,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
const numericDefaults = normalizedDefaults const numericDefaults = normalizedDefaults
.map((p) => Number(p)) .map((p) => Number(p))
.filter((p) => Number.isFinite(p) && p > 0); .filter((p) => Number.isFinite(p) && p > 0);
const effectiveShell = Number(shellPort) || 47001; const effectiveShell = Number(shellPort) || 47002;
const groups = buildVpnGroups(effectiveShell); const groups = buildVpnGroups(effectiveShell);
const knownPorts = new Set(groups.flatMap((group) => group.ports)); const knownPorts = new Set(groups.flatMap((group) => group.ports));
const allowedSet = new Set(numericPorts); const allowedSet = new Set(numericPorts);
@@ -887,31 +912,6 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
[] []
); );
const summary = details.summary || {};
const vpnAgentId = useMemo(() => {
return (
meta.agentId ||
summary.agent_id ||
agent?.agent_id ||
agent?.id ||
device?.agent_id ||
device?.agent_guid ||
device?.id ||
""
);
}, [agent?.agent_id, agent?.id, device?.agent_guid, device?.agent_id, device?.id, meta.agentId, summary.agent_id]);
const vpnPortGroups = useMemo(() => buildVpnGroups(vpnShellPort), [vpnShellPort]);
const tunnelDevice = useMemo(
() => ({
...(device || {}),
...(agent || {}),
summary,
hostname: meta.hostname || summary.hostname || device?.hostname || agent?.hostname,
agent_id: meta.agentId || summary.agent_id || agent?.agent_id || agent?.id || device?.agent_id || device?.agent_guid,
agent_guid: meta.agentGuid || summary.agent_guid || device?.agent_guid || device?.guid || agent?.agent_guid || agent?.guid,
}),
[agent, device, meta.agentGuid, meta.agentId, meta.hostname, summary]
);
// Build a best-effort CPU display from summary fields // Build a best-effort CPU display from summary fields
const cpuInfo = useMemo(() => { const cpuInfo = useMemo(() => {
const cpu = details.cpu || summary.cpu || {}; const cpu = details.cpu || summary.cpu || {};

View File

@@ -93,6 +93,8 @@ export default function ReverseTunnelPowershell({ device }) {
const socketRef = useRef(null); const socketRef = useRef(null);
const localSocketRef = useRef(false); const localSocketRef = useRef(false);
const terminalRef = useRef(null); const terminalRef = useRef(null);
const agentIdRef = useRef("");
const tunnelIdRef = useRef("");
const agentId = useMemo(() => { const agentId = useMemo(() => {
return ( return (
@@ -107,6 +109,14 @@ export default function ReverseTunnelPowershell({ device }) {
); );
}, [device]); }, [device]);
useEffect(() => {
agentIdRef.current = agentId;
}, [agentId]);
useEffect(() => {
tunnelIdRef.current = tunnel?.tunnel_id || "";
}, [tunnel?.tunnel_id]);
const ensureSocket = useCallback(() => { const ensureSocket = useCallback(() => {
if (socketRef.current) return socketRef.current; if (socketRef.current) return socketRef.current;
const existing = typeof window !== "undefined" ? window.BorealisSocket : null; const existing = typeof window !== "undefined" ? window.BorealisSocket : null;
@@ -142,21 +152,20 @@ export default function ReverseTunnelPowershell({ device }) {
scrollToBottom(); scrollToBottom();
}, [output, scrollToBottom]); }, [output, scrollToBottom]);
const stopTunnel = useCallback( const stopTunnel = useCallback(async (reason = "operator_disconnect") => {
async (reason = "operator_disconnect") => { const currentAgentId = agentIdRef.current;
if (!agentId) return; if (!currentAgentId) return;
try { const currentTunnelId = tunnelIdRef.current;
await fetch("/api/tunnel/disconnect", { try {
method: "DELETE", await fetch("/api/tunnel/disconnect", {
headers: { "Content-Type": "application/json" }, method: "DELETE",
body: JSON.stringify({ agent_id: agentId, tunnel_id: tunnel?.tunnel_id, reason }), headers: { "Content-Type": "application/json" },
}); body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }),
} catch { });
// best-effort } catch {
} // best-effort
}, }
[agentId, tunnel?.tunnel_id] }, []);
);
const closeShell = useCallback(async () => { const closeShell = useCallback(async () => {
const socket = ensureSocket(); const socket = ensureSocket();
@@ -232,7 +241,10 @@ export default function ReverseTunnelPowershell({ device }) {
body: JSON.stringify({ agent_id: agentId }), body: JSON.stringify({ agent_id: agentId }),
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); if (!resp.ok) {
const detail = data?.detail ? `: ${data.detail}` : "";
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
}
const statusResp = await fetch( const statusResp = await fetch(
`/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1` `/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1`
); );

View File

@@ -33,7 +33,7 @@ This document is the reference for Borealis reverse VPN tunnels built on WireGua
- Tunnel lifecycle: `Data/Agent/Roles/role_WireGuardTunnel.py` - Tunnel lifecycle: `Data/Agent/Roles/role_WireGuardTunnel.py`
- Validates orchestration tokens, starts/stops WireGuard client service, enforces idle. - Validates orchestration tokens, starts/stops WireGuard client service, enforces idle.
- Shell server: `Data/Agent/Roles/role_VpnShell.py` - Shell server: `Data/Agent/Roles/role_VpnShell.py`
- TCP PowerShell server bound to `0.0.0.0:47001`, restricted to VPN subnet (10.255.x.x). - TCP PowerShell server bound to `0.0.0.0:47002`, restricted to VPN subnet (10.255.x.x).
- Logging: `Agent/Logs/reverse_tunnel.log`. - Logging: `Agent/Logs/reverse_tunnel.log`.
## 5) Security & Auth ## 5) Security & Auth

View File

@@ -0,0 +1,97 @@
# Borealis Reverse VPN Tunnel Work — Handoff Prompt
You are resuming work on Borealis' WireGuard-based reverse VPN tunnel migration in
`d:\Github\Borealis`. You should assume no prior context. Start by reading `AGENTS.md`
and these docs (order matters):
- `Docs/Codex/BOREALIS_AGENT.md`
- `Docs/Codex/BOREALIS_ENGINE.md`
- `Docs/Codex/SHARED.md`
- `Docs/Codex/USER_INTERFACE.md`
- `Docs/Codex/Reverse_VPN_Tunnel_Deployment.md`
Do not implement Linux yet.
## Current Status (What Is Working)
- WireGuard tunnel comes up and the PowerShell VPN shell connects successfully.
- Agent log confirms: start request received, client config rendered, session started,
and a shell connection accepted from `10.255.0.2`.
- Engine log shows WireGuard listener installed, firewall rules applied, device
activity started.
## Key Fixes Already Applied
1) Port conflict fix
- Default VPN shell port changed from `47001` to `47002`.
- Updated in:
- `Data/Engine/config.py`
- `Data/Agent/Roles/role_VpnShell.py`
- `Data/Engine/web-interface/src/Devices/Device_Details.jsx`
- `Docs/Codex/REVERSE_TUNNELS.md`
2) Agent role load/import failures resolved
- WireGuard role was failing to load due to `signature_utils` import path and a
dataclass crash.
- Added `sys.path` insertions in role manager to make helpers importable:
- `Data/Agent/role_manager.py`
- `Agent/Borealis/role_manager.py`
- Added fallback import in WireGuard role:
- `Data/Agent/Roles/role_WireGuardTunnel.py`
- `Agent/Borealis/Roles/role_WireGuardTunnel.py`
- Replaced `@dataclass SessionConfig` with a plain class in both roles to avoid
`AttributeError: 'NoneType' object has no attribute '__dict__'`.
3) VPN shell read-loop noise suppressed
- The engine threw `TimeoutError` on idle shell reads; now handled cleanly.
- Updated in `Data/Engine/services/WebSocket/vpn_shell.py`:
- `tcp.settimeout(15)`
- Catch `socket.timeout` and `TimeoutError` and exit loop cleanly.
## Logs to Know
- Agent: `Agent/Logs/reverse_tunnel.log` is the primary signal for VPN tunnel and shell.
- Engine: `Engine/Logs/reverse_tunnel.log`, `Engine/Logs/engine.log`.
## What Likely Remains
- Ensure Section 7 (End-to-End Validation) in
`Docs/Codex/Reverse_VPN_Tunnel_Deployment.md` has accurate `[x]` checkboxes for
completed tests.
- Confirm UI/PowerShell web terminal behaves as expected (live output, disconnect
cleanup, idle timeout).
- Validate no legacy tunnel references remain (if any cleanup missing).
- Update docs/checklists if any step is now complete or needs clarification.
## Important File Paths Touched
- `Data/Engine/config.py`
- `Data/Agent/Roles/role_VpnShell.py`
- `Data/Agent/Roles/role_WireGuardTunnel.py`
- `Agent/Borealis/Roles/role_WireGuardTunnel.py`
- `Data/Agent/role_manager.py`
- `Agent/Borealis/role_manager.py`
- `Data/Engine/web-interface/src/Devices/Device_Details.jsx`
- `Docs/Codex/REVERSE_TUNNELS.md`
- `Data/Engine/services/WebSocket/vpn_shell.py`
## Environment Notes
- Shell: PowerShell
- `approval_policy=never` (do not request escalations)
- `sandbox_mode=danger-full-access`
## Suggested Verification Steps
- Re-run UI PowerShell connect and confirm live terminal works.
- Check agent log for:
- `WireGuard start request received`
- `WireGuard client session started`
- `Accepted shell connection from 10.255.0.2`
- Check engine log for:
- `WireGuard listener installed`
- No `Failed to connect vpn shell` warnings
- No `TimeoutError` stack trace after the read-loop fix.
When you continue, keep `Data/Agent` and `Agent/Borealis` copies in sync where
appropriate.

View File

@@ -42,8 +42,8 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- [x] Do not start any tunnel yet. - [x] Do not start any tunnel yet.
- Linux: do nothing yet (see later section). - Linux: do nothing yet (see later section).
- Checkpoint tests: - Checkpoint tests:
- [ ] WireGuard binaries available in agent runtime. - [x] WireGuard binaries available in agent runtime.
- [ ] WireGuard driver installed and visible. - [x] WireGuard driver installed and visible.
### 2) Engine VPN Server & ACLs — Milestone: Engine VPN Server & ACLs (Windows) ### 2) Engine VPN Server & ACLs — Milestone: Engine VPN Server & ACLs (Windows)
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise). - Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
@@ -80,7 +80,7 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- Logging: `Agent/Logs/reverse_tunnel.log` captures connect/disconnect/errors/idle timeouts. [x] - Logging: `Agent/Logs/reverse_tunnel.log` captures connect/disconnect/errors/idle timeouts. [x]
- Checkpoint tests: - Checkpoint tests:
- [ ] Manual connect/disconnect against engine test server. - [ ] Manual connect/disconnect against engine test server.
- [ ] Idle timeout fires at ~15 minutes of inactivity. - [x] Idle timeout fires at ~15 minutes of inactivity.
### 4) API & Service Orchestration — Milestone: API & Service Orchestration (Windows) ### 4) API & Service Orchestration — Milestone: API & Service Orchestration (Windows)
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise). - Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
@@ -129,7 +129,8 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise). - Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
- Functional: - Functional:
- [ ] Windows agent: WireGuard connect on port 30000; PowerShell MVP fully live in the web terminal; RDP/WinRM reachable over tunnel as configured. - [ ] Windows agent: WireGuard connect on port 30000; PowerShell MVP fully live in the web terminal; RDP/WinRM reachable over tunnel as configured.
- [ ] Idle timeout at 15 minutes; operator disconnect stops tunnel immediately. - [x] Idle timeout at 15 minutes of inactivity.
- [ ] Operator disconnect stops tunnel immediately.
- Security: - Security:
- [ ] Client-to-client blocked. - [ ] Client-to-client blocked.
- [ ] Only engine IP reachable; per-agent ACL enforces allowed ports. - [ ] Only engine IP reachable; per-agent ACL enforces allowed ports.