mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2026-02-06 12:10:32 -07:00
Removed RDP in favor of VNC / Made WireGuard Tunnel Persistent
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -19,9 +19,8 @@ database.db
|
|||||||
/Dependencies/Python/
|
/Dependencies/Python/
|
||||||
/Dependencies/AutoHotKey/
|
/Dependencies/AutoHotKey/
|
||||||
/Dependencies/git/
|
/Dependencies/git/
|
||||||
/Dependencies/VPN_Tunnel_Adapter/*
|
/Dependencies/VPN_Tunnel_Adapter/
|
||||||
!/Dependencies/VPN_Tunnel_Adapter/README.md
|
/Dependencies/UltraVNC_Server/
|
||||||
!/Dependencies/VPN_Tunnel_Adapter/.gitkeep
|
|
||||||
/Data/Engine/Python_API_Endpoints/
|
/Data/Engine/Python_API_Endpoints/
|
||||||
|
|
||||||
# Misc Files/Folders
|
# Misc Files/Folders
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Borealis Codex Engagement Index
|
# Borealis Codex Engagement Index
|
||||||
|
|
||||||
Use this file as the entrypoint for Codex instructions. The full knowledgebase now lives under `Docs/` and includes both human-facing guidance and **Codex Agent** sections with deep, agent-ready details. There is no separate Codex folder anymore.
|
Use this file as the entrypoint for Codex instructions. The full knowledgebase now lives under `Docs/` and includes both human-facing guidance and **Codex Agent** sections with deep, agent-ready details.
|
||||||
|
|
||||||
## Where to Read
|
## Where to Read
|
||||||
- Start here: `Docs/index.md` (table of contents and documentation rules).
|
- Start here: `Docs/index.md` (table of contents and documentation rules).
|
||||||
- Agent runtime: `Docs/agent-runtime.md` (runtime paths, logging, security, roles, platform parity, Ansible status).
|
- Agent runtime: `Docs/agent-runtime.md` (runtime paths, logging, security, roles, platform parity, Ansible status).
|
||||||
- Engine runtime: `Docs/engine-runtime.md` (architecture, logging, security/API parity, platform parity, migration notes).
|
- Engine runtime: `Docs/engine-runtime.md` (architecture, logging, security/API parity, platform parity, migration notes).
|
||||||
- UI and notifications: `Docs/ui-and-notifications.md` (MagicUI styling, AG Grid rules, toast notifications, UI handoffs).
|
- UI and notifications: `Docs/ui-and-notifications.md` (MagicUI styling, AG Grid rules, toast notifications, UI handoffs).
|
||||||
- VPN and remote access: `Docs/vpn-and-remote-access.md` (WireGuard tunnels, remote shell, RDP, troubleshooting context).
|
- VPN and remote access: `Docs/vpn-and-remote-access.md` (WireGuard tunnels, remote shell, VNC, troubleshooting context).
|
||||||
- Security and trust: `Docs/security-and-trust.md` (enrollment, tokens, code signing, sequence diagrams).
|
- Security and trust: `Docs/security-and-trust.md` (enrollment, tokens, code signing, sequence diagrams).
|
||||||
|
|
||||||
Precedence: follow domain docs first; where overlap exists, the domain page wins. The Codex Agent sections inside each page are the authoritative agent guidance.
|
Precedence: follow domain docs first; where overlap exists, the domain page wins. The Codex Agent sections inside each page are the authoritative agent guidance.
|
||||||
|
|||||||
146
Borealis.ps1
146
Borealis.ps1
@@ -1501,7 +1501,151 @@ ListenPort = 0
|
|||||||
throw "$logPrefix Driver still missing after adapter provisioning and pnputil fallback."
|
throw "$logPrefix Driver still missing after adapter provisioning and pnputil fallback."
|
||||||
}
|
}
|
||||||
|
|
||||||
# AutoHotKey portable
|
# UltraVNC Server (on-demand VNC backend for noVNC)
|
||||||
|
Run-Step "Dependency: UltraVNC Server" {
|
||||||
|
$uvncZipUrl = $env:BOREALIS_ULTRAVNC_ZIP_URL
|
||||||
|
if (-not $uvncZipUrl) {
|
||||||
|
$uvncZipUrl = "https://uvnc.eu/download/1640/UltraVNC_1640.zip"
|
||||||
|
}
|
||||||
|
$uvncMsiUrl = $env:BOREALIS_ULTRAVNC_MSI_URL
|
||||||
|
if (-not $uvncMsiUrl) {
|
||||||
|
$uvncMsiUrl = "https://uvnc.eu/download/1640/UltraVNC_1640_x64_Setup.msi"
|
||||||
|
}
|
||||||
|
$uvncInstallerUrl = $env:BOREALIS_ULTRAVNC_URL
|
||||||
|
if (-not $uvncInstallerUrl) {
|
||||||
|
$uvncInstallerUrl = "https://uvnc.eu/download/1640/UltraVNC_1640_x64_Setup.exe"
|
||||||
|
}
|
||||||
|
$uvncRoot = Join-Path $depsRoot "UltraVNC_Server"
|
||||||
|
$uvncPayloadRoot = Join-Path $uvncRoot "payload"
|
||||||
|
$uvncZipPath = Join-Path $uvncRoot "UltraVNC_1640.zip"
|
||||||
|
$uvncMsiPath = Join-Path $uvncRoot "UltraVNC_1640_x64_Setup.msi"
|
||||||
|
$uvncInstallerPath = Join-Path $uvncRoot "UltraVNC_1640_x64_Setup.exe"
|
||||||
|
|
||||||
|
if (-not (Test-Path $uvncRoot)) {
|
||||||
|
New-Item -ItemType Directory -Path $uvncRoot -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$uvncExe = Get-ChildItem -Path $uvncRoot -Recurse -Filter "winvnc*.exe" -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $uvncExe) {
|
||||||
|
if (-not (Test-Path $sevenZipExe)) {
|
||||||
|
throw "7-Zip CLI not found at: $sevenZipExe"
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path $uvncZipPath)) {
|
||||||
|
Invoke-WebRequest -Uri $uvncZipUrl -OutFile $uvncZipPath
|
||||||
|
}
|
||||||
|
if (Test-Path $uvncPayloadRoot) {
|
||||||
|
Remove-Item $uvncPayloadRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $uvncPayloadRoot -Force | Out-Null
|
||||||
|
& $sevenZipExe x $uvncZipPath "-o$uvncPayloadRoot" -y | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Host "UltraVNC zip download/extract failed. Trying MSI fallback." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
$uvncExe = Get-ChildItem -Path $uvncPayloadRoot -Recurse -Filter "winvnc*.exe" -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $uvncExe) {
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path $uvncMsiPath)) {
|
||||||
|
Invoke-WebRequest -Uri $uvncMsiUrl -OutFile $uvncMsiPath
|
||||||
|
}
|
||||||
|
$msiExtractRoot = Join-Path $uvncPayloadRoot "msi_extract"
|
||||||
|
if (Test-Path $msiExtractRoot) {
|
||||||
|
Remove-Item $msiExtractRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $msiExtractRoot -Force | Out-Null
|
||||||
|
$msiArgs = @(
|
||||||
|
"/a",
|
||||||
|
"`"$uvncMsiPath`"",
|
||||||
|
"/qn",
|
||||||
|
"TARGETDIR=`"$msiExtractRoot`""
|
||||||
|
)
|
||||||
|
$msiProc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Wait -NoNewWindow -PassThru
|
||||||
|
if ($msiProc.ExitCode -ne 0) {
|
||||||
|
Write-Host "UltraVNC MSI extraction failed with code $($msiProc.ExitCode). Trying installer fallback." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "UltraVNC MSI extraction failed. Trying installer fallback." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
$uvncExe = Get-ChildItem -Path $uvncPayloadRoot -Recurse -Filter "winvnc*.exe" -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $uvncExe) {
|
||||||
|
if (-not (Test-Path $uvncInstallerPath)) {
|
||||||
|
Invoke-WebRequest -Uri $uvncInstallerUrl -OutFile $uvncInstallerPath
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $sevenZipExe)) {
|
||||||
|
throw "7-Zip CLI not found at: $sevenZipExe"
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path $uvncPayloadRoot)) {
|
||||||
|
New-Item -ItemType Directory -Path $uvncPayloadRoot -Force | Out-Null
|
||||||
|
}
|
||||||
|
& $sevenZipExe x $uvncInstallerPath "-o$uvncPayloadRoot" -y | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Host "UltraVNC installer extraction failed." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
$uvncExe = Get-ChildItem -Path $uvncPayloadRoot -Recurse -Filter "winvnc*.exe" -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$passwordTool = Get-ChildItem -Path $uvncRoot -Recurse -Filter "createpassword.exe" -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $passwordTool) {
|
||||||
|
$passwordToolUrl = $env:BOREALIS_VNC_PASSWORD_TOOL_URL
|
||||||
|
if (-not $passwordToolUrl) {
|
||||||
|
$passwordToolUrl = "https://uvnc.eu/download/133/createpassword.zip"
|
||||||
|
}
|
||||||
|
$passwordToolZip = Join-Path $uvncRoot "createpassword.zip"
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path $passwordToolZip)) {
|
||||||
|
Invoke-WebRequest -Uri $passwordToolUrl -OutFile $passwordToolZip
|
||||||
|
}
|
||||||
|
if (Test-Path $sevenZipExe) {
|
||||||
|
$toolDir = Join-Path $uvncRoot "tools"
|
||||||
|
if (Test-Path $toolDir) {
|
||||||
|
Remove-Item $toolDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $toolDir | Out-Null
|
||||||
|
& $sevenZipExe x $passwordToolZip "-o$toolDir" -y | Out-Null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "UltraVNC createpassword tool download failed. Set BOREALIS_VNC_PASSWORD_TOOL_URL to override." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $uvncExe) {
|
||||||
|
Write-Host "UltraVNC server binary not found. Ensure winvnc.exe exists under Dependencies\\UltraVNC_Server." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
$uvncServiceName = $env:BOREALIS_ULTRAVNC_SERVICE
|
||||||
|
if (-not $uvncServiceName) {
|
||||||
|
$uvncServiceName = "uvnc_service"
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
& $uvncExe.FullName -install | Out-Null
|
||||||
|
$uvncService = Get-Service -Name $uvncServiceName -ErrorAction SilentlyContinue
|
||||||
|
if (-not $uvncService) {
|
||||||
|
$uvncService = Get-Service -ErrorAction SilentlyContinue | Where-Object {
|
||||||
|
$_.Name -like "*uvnc*" -or $_.DisplayName -like "*UltraVNC*"
|
||||||
|
} | Select-Object -First 1
|
||||||
|
}
|
||||||
|
if ($uvncService) {
|
||||||
|
sc.exe config $uvncService.Name start= auto | Out-Null
|
||||||
|
sc.exe start $uvncService.Name | Out-Null
|
||||||
|
} else {
|
||||||
|
Write-Host "UltraVNC service not found after install. Set BOREALIS_ULTRAVNC_SERVICE to override." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "UltraVNC service setup failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Run-Step "Dependency: AutoHotKey" {
|
Run-Step "Dependency: AutoHotKey" {
|
||||||
$ahkVersion = "2.0.19"
|
$ahkVersion = "2.0.19"
|
||||||
$ahkVersionTag = "v$ahkVersion"
|
$ahkVersionTag = "v$ahkVersion"
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
# ======================================================
|
|
||||||
# Data\Agent\Roles\role_RDP.py
|
|
||||||
# Description: Optional RDP readiness helper for Borealis (Windows-only).
|
|
||||||
#
|
|
||||||
# API Endpoints (if applicable): None
|
|
||||||
# ======================================================
|
|
||||||
|
|
||||||
"""RDP readiness helper role (no-op unless enabled via environment flags)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROLE_NAME = "RDP"
|
|
||||||
ROLE_CONTEXTS = ["system"]
|
|
||||||
|
|
||||||
|
|
||||||
def _log_path() -> Path:
|
|
||||||
root = Path(__file__).resolve().parents[2] / "Logs"
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
return root / "rdp.log"
|
|
||||||
|
|
||||||
|
|
||||||
def _write_log(message: str) -> None:
|
|
||||||
ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
|
|
||||||
try:
|
|
||||||
_log_path().open("a", encoding="utf-8").write(f"[{ts}] [rdp-role] {message}\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _bool_env(name: str) -> bool:
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None:
|
|
||||||
return False
|
|
||||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def _enable_rdp_windows() -> None:
|
|
||||||
command = (
|
|
||||||
"Set-ItemProperty -Path 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server' "
|
|
||||||
"-Name fDenyTSConnections -Value 0; "
|
|
||||||
"Set-Service -Name TermService -StartupType Automatic; "
|
|
||||||
"Start-Service -Name TermService; "
|
|
||||||
"Enable-NetFirewallRule -DisplayGroup 'Remote Desktop'"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["powershell.exe", "-NoProfile", "-Command", command],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
_write_log(f"RDP enable failed: {result.stderr.strip()}")
|
|
||||||
else:
|
|
||||||
_write_log("RDP enable applied (registry/service/firewall).")
|
|
||||||
except Exception as exc:
|
|
||||||
_write_log(f"RDP enable failed: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
class Role:
|
|
||||||
def __init__(self, ctx) -> None:
|
|
||||||
self.ctx = ctx
|
|
||||||
auto_enable = _bool_env("BOREALIS_RDP_AUTO_ENABLE")
|
|
||||||
_write_log(f"RDP role loaded auto_enable={auto_enable}")
|
|
||||||
if auto_enable and os.name == "nt":
|
|
||||||
_enable_rdp_windows()
|
|
||||||
|
|
||||||
def register_events(self) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
def stop_all(self) -> None:
|
|
||||||
return
|
|
||||||
468
Data/Agent/Roles/role_VNC.py
Normal file
468
Data/Agent/Roles/role_VNC.py
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
# ======================================================
|
||||||
|
# Data/Agent/Roles/role_VNC.py
|
||||||
|
# Description: On-demand UltraVNC server lifecycle over WireGuard.
|
||||||
|
#
|
||||||
|
# API Endpoints (if applicable): None
|
||||||
|
# ======================================================
|
||||||
|
|
||||||
|
"""UltraVNC role (Windows) for on-demand VNC sessions over WireGuard."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
ROLE_NAME = "VNC"
|
||||||
|
ROLE_CONTEXTS = ["system"]
|
||||||
|
|
||||||
|
VNC_FIREWALL_RULE_NAME = "Borealis - VNC - UltraVNC"
|
||||||
|
DEFAULT_VNC_PORT = 5900
|
||||||
|
ULTRAVNC_SERVICE_NAME = os.environ.get("BOREALIS_ULTRAVNC_SERVICE") or "uvnc_service"
|
||||||
|
|
||||||
|
|
||||||
|
def _log_path() -> Path:
|
||||||
|
root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root / "vnc.log"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_log(message: str) -> None:
|
||||||
|
ts = time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
|
||||||
|
try:
|
||||||
|
_log_path().open("a", encoding="utf-8").write(f"[{ts}] [vnc] {message}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _find_project_root() -> Optional[Path]:
|
||||||
|
override = os.environ.get("BOREALIS_ROOT") or os.environ.get("BOREALIS_PROJECT_ROOT")
|
||||||
|
if override:
|
||||||
|
try:
|
||||||
|
override_path = Path(override).expanduser().resolve()
|
||||||
|
if override_path.is_dir():
|
||||||
|
return override_path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
current = Path(__file__).resolve()
|
||||||
|
for parent in (current, *current.parents):
|
||||||
|
try:
|
||||||
|
if (parent / "Borealis.ps1").is_file() or (parent / "users.json").is_file():
|
||||||
|
return parent
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return current.parents[3]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_vnc_root() -> Optional[Path]:
|
||||||
|
root = _find_project_root()
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
candidate = root / "Dependencies" / "UltraVNC_Server"
|
||||||
|
if candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_vnc_exe() -> Optional[str]:
|
||||||
|
override = os.environ.get("BOREALIS_VNC_SERVER_BIN")
|
||||||
|
if override:
|
||||||
|
try:
|
||||||
|
if Path(override).is_file():
|
||||||
|
return str(Path(override))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
vnc_root = _resolve_vnc_root()
|
||||||
|
if vnc_root:
|
||||||
|
preferred = [
|
||||||
|
vnc_root / "payload" / "x64" / "winvnc.exe",
|
||||||
|
vnc_root / "payload" / "x64" / "winvnc64.exe",
|
||||||
|
vnc_root / "winvnc64.exe",
|
||||||
|
vnc_root / "winvnc.exe",
|
||||||
|
vnc_root / "payload" / "x86" / "winvnc.exe",
|
||||||
|
]
|
||||||
|
for candidate in preferred:
|
||||||
|
if candidate.is_file():
|
||||||
|
return str(candidate)
|
||||||
|
try:
|
||||||
|
for candidate in vnc_root.rglob("winvnc*.exe"):
|
||||||
|
if candidate.is_file():
|
||||||
|
return str(candidate)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_vnc_port(value: Any = None) -> int:
|
||||||
|
raw = value if value is not None else os.environ.get("BOREALIS_VNC_PORT")
|
||||||
|
try:
|
||||||
|
port = int(raw) if raw is not None else DEFAULT_VNC_PORT
|
||||||
|
except Exception:
|
||||||
|
port = DEFAULT_VNC_PORT
|
||||||
|
if port < 1 or port > 65535:
|
||||||
|
return DEFAULT_VNC_PORT
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_vnc_password_tool(root: Optional[Path]) -> Optional[str]:
|
||||||
|
override = os.environ.get("BOREALIS_VNC_PASSWORD_TOOL")
|
||||||
|
if override:
|
||||||
|
try:
|
||||||
|
if Path(override).is_file():
|
||||||
|
return str(Path(override))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
candidates: list[Path] = []
|
||||||
|
if root:
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
root / "createpassword.exe",
|
||||||
|
root / "tools" / "createpassword.exe",
|
||||||
|
root / "createpassword64.exe",
|
||||||
|
root / "tools" / "createpassword64.exe",
|
||||||
|
root / "CreatePassword.exe",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
vnc_root = _resolve_vnc_root()
|
||||||
|
if vnc_root and vnc_root != root:
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
vnc_root / "createpassword.exe",
|
||||||
|
vnc_root / "tools" / "createpassword.exe",
|
||||||
|
vnc_root / "createpassword64.exe",
|
||||||
|
vnc_root / "tools" / "createpassword64.exe",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.is_file():
|
||||||
|
return str(candidate)
|
||||||
|
try:
|
||||||
|
for candidate in root.rglob("createpassword.exe"):
|
||||||
|
if candidate.is_file():
|
||||||
|
return str(candidate)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_ultravnc_ini(config_dir: Path, port: int) -> Optional[Path]:
|
||||||
|
try:
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ini_path = config_dir / "ultravnc.ini"
|
||||||
|
try:
|
||||||
|
lines = [
|
||||||
|
"UseRegistry=0",
|
||||||
|
"AuthRequired=1",
|
||||||
|
"MSLogonRequired=0",
|
||||||
|
"NewMSLogon=0",
|
||||||
|
f"PortNumber={port}",
|
||||||
|
"AutoPortSelect=0",
|
||||||
|
"SocketConnect=1",
|
||||||
|
"HTTPConnect=0",
|
||||||
|
"AllowShutdown=0",
|
||||||
|
"DisableTrayIcon=1",
|
||||||
|
"EnableFileTransfer=0",
|
||||||
|
]
|
||||||
|
ini_path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
return ini_path
|
||||||
|
except Exception as exc:
|
||||||
|
_write_log(f"Failed to write ultravnc.ini: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_allowed_ips(value: Any) -> Optional[str]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return str(value[0])
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class VncManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._last_port: Optional[int] = None
|
||||||
|
self._last_password: Optional[str] = None
|
||||||
|
self._vnc_exe = _resolve_vnc_exe()
|
||||||
|
self._password_tool: Optional[str] = None
|
||||||
|
|
||||||
|
def _service_state(self) -> Optional[str]:
|
||||||
|
if os.name != "nt":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sc.exe", "query", ULTRAVNC_SERVICE_NAME],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
for line in (result.stdout or "").splitlines():
|
||||||
|
if "STATE" in line:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if parts:
|
||||||
|
return parts[-1].upper()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _restart_service(self) -> None:
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
state = self._service_state()
|
||||||
|
if state != "RUNNING":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subprocess.run(["sc.exe", "stop", ULTRAVNC_SERVICE_NAME], capture_output=True, text=True, check=False)
|
||||||
|
time.sleep(1)
|
||||||
|
subprocess.run(["sc.exe", "start", ULTRAVNC_SERVICE_NAME], capture_output=True, text=True, check=False)
|
||||||
|
except Exception as exc:
|
||||||
|
_write_log(f"Failed to restart UltraVNC service: {exc}")
|
||||||
|
|
||||||
|
def _ensure_service_running(self) -> bool:
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
state = self._service_state()
|
||||||
|
if state == "RUNNING":
|
||||||
|
return True
|
||||||
|
if not self._vnc_exe:
|
||||||
|
self._vnc_exe = _resolve_vnc_exe()
|
||||||
|
if not self._vnc_exe:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if state is None:
|
||||||
|
subprocess.run([self._vnc_exe, "-install"], capture_output=True, text=True, check=False)
|
||||||
|
subprocess.run(
|
||||||
|
["sc.exe", "config", ULTRAVNC_SERVICE_NAME, "start=", "auto"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
subprocess.run(["sc.exe", "start", ULTRAVNC_SERVICE_NAME], capture_output=True, text=True, check=False)
|
||||||
|
except Exception as exc:
|
||||||
|
_write_log(f"Failed to ensure UltraVNC service running: {exc}")
|
||||||
|
return False
|
||||||
|
return self._service_state() == "RUNNING"
|
||||||
|
|
||||||
|
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 VNC firewall rule; invalid allowed_ips={allowed_ips}.")
|
||||||
|
return None
|
||||||
|
if network.prefixlen != 32:
|
||||||
|
_write_log(f"Refusing to apply VNC firewall rule; allowed_ips not /32: {network}.")
|
||||||
|
return None
|
||||||
|
return str(network)
|
||||||
|
|
||||||
|
def _ensure_firewall(self, allowed_ips: Optional[str], port: int) -> None:
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
remote = self._normalize_firewall_remote(allowed_ips)
|
||||||
|
if not remote:
|
||||||
|
return
|
||||||
|
rule_name = VNC_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=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 VNC firewall rule: {result.stderr.strip()}")
|
||||||
|
else:
|
||||||
|
_write_log(f"Ensured VNC firewall rule for {remote} on port {port}.")
|
||||||
|
except Exception as exc:
|
||||||
|
_write_log(f"Failed to ensure VNC firewall rule: {exc}")
|
||||||
|
|
||||||
|
def _remove_firewall(self) -> None:
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
rule_name = VNC_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 _apply_password(self, config_dir: Path, password: str) -> Optional[str]:
|
||||||
|
if not password:
|
||||||
|
_write_log("VNC password missing; refusing to start without auth.")
|
||||||
|
return None
|
||||||
|
trimmed = str(password)[:8]
|
||||||
|
if trimmed != password:
|
||||||
|
_write_log("VNC password trimmed to 8 characters for UltraVNC compatibility.")
|
||||||
|
if not self._password_tool:
|
||||||
|
self._password_tool = _resolve_vnc_password_tool(config_dir)
|
||||||
|
if not self._password_tool:
|
||||||
|
_write_log("VNC password tool not found; expected createpassword.exe under Dependencies/UltraVNC_Server.")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[self._password_tool, "-secure", trimmed],
|
||||||
|
cwd=str(config_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
_write_log(f"Failed to apply VNC password: {result.stderr.strip()}")
|
||||||
|
return None
|
||||||
|
return trimmed
|
||||||
|
except Exception as exc:
|
||||||
|
_write_log(f"Failed to apply VNC password: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
port: Optional[int],
|
||||||
|
allowed_ips: Optional[str],
|
||||||
|
password: Optional[str],
|
||||||
|
reason: str = "start",
|
||||||
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
port_value = _resolve_vnc_port(port)
|
||||||
|
self._ensure_firewall(allowed_ips, port_value)
|
||||||
|
|
||||||
|
if not self._vnc_exe:
|
||||||
|
self._vnc_exe = _resolve_vnc_exe()
|
||||||
|
if not self._vnc_exe:
|
||||||
|
_write_log("UltraVNC server binary not found; expected under Dependencies/UltraVNC_Server.")
|
||||||
|
return
|
||||||
|
|
||||||
|
exe_path = Path(self._vnc_exe)
|
||||||
|
config_dir = exe_path.parent
|
||||||
|
ini_path = _ensure_ultravnc_ini(config_dir, port_value)
|
||||||
|
if not ini_path:
|
||||||
|
return
|
||||||
|
applied_password = self._apply_password(config_dir, password or "")
|
||||||
|
if not applied_password:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._ensure_service_running():
|
||||||
|
_write_log("Failed to start UltraVNC service.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._last_port != port_value or self._last_password != applied_password:
|
||||||
|
self._restart_service()
|
||||||
|
self._last_port = port_value
|
||||||
|
self._last_password = applied_password
|
||||||
|
_write_log(f"VNC service running port={port_value} reason={reason}.")
|
||||||
|
|
||||||
|
def stop(self, *, reason: str = "stop") -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._remove_firewall()
|
||||||
|
self._last_port = None
|
||||||
|
_write_log(f"VNC firewall closed reason={reason}.")
|
||||||
|
|
||||||
|
|
||||||
|
class Role:
|
||||||
|
def __init__(self, ctx) -> None:
|
||||||
|
self.ctx = ctx
|
||||||
|
self.vnc = VncManager()
|
||||||
|
self._last_allowed_ips: Optional[str] = None
|
||||||
|
hooks = getattr(ctx, "hooks", {}) or {}
|
||||||
|
self._log_hook = hooks.get("log_agent")
|
||||||
|
try:
|
||||||
|
self.vnc.stop(reason="agent_startup")
|
||||||
|
except Exception:
|
||||||
|
self._log("Failed to preflight VNC cleanup.", error=True)
|
||||||
|
try:
|
||||||
|
self.vnc._ensure_service_running()
|
||||||
|
except Exception:
|
||||||
|
self._log("Failed to ensure UltraVNC service running.", error=True)
|
||||||
|
|
||||||
|
def _log(self, message: str, *, error: bool = False) -> None:
|
||||||
|
if callable(self._log_hook):
|
||||||
|
try:
|
||||||
|
self._log_hook(message, fname="VPN_Tunnel/vnc.log")
|
||||||
|
if error:
|
||||||
|
self._log_hook(message, fname="agent.error.log")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_write_log(message)
|
||||||
|
|
||||||
|
def register_events(self) -> None:
|
||||||
|
sio = self.ctx.sio
|
||||||
|
|
||||||
|
@sio.on("vpn_tunnel_start")
|
||||||
|
async def _vpn_tunnel_start(payload):
|
||||||
|
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
|
||||||
|
allowed_ips = payload.get("allowed_ips")
|
||||||
|
self._last_allowed_ips = _parse_allowed_ips(allowed_ips)
|
||||||
|
|
||||||
|
@sio.on("vpn_tunnel_stop")
|
||||||
|
async def _vpn_tunnel_stop(payload):
|
||||||
|
reason = "server_stop"
|
||||||
|
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
|
||||||
|
self._log(f"VNC stop requested (reason={reason}).")
|
||||||
|
self.vnc.stop(reason=str(reason))
|
||||||
|
|
||||||
|
@sio.on("vnc_start")
|
||||||
|
async def _vnc_start(payload):
|
||||||
|
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
|
||||||
|
port = payload.get("port")
|
||||||
|
allowed_ips = payload.get("allowed_ips") or self._last_allowed_ips
|
||||||
|
password = payload.get("password") or ""
|
||||||
|
reason = payload.get("reason") or "vnc_session_start"
|
||||||
|
else:
|
||||||
|
port = None
|
||||||
|
allowed_ips = self._last_allowed_ips
|
||||||
|
password = ""
|
||||||
|
reason = "vnc_session_start"
|
||||||
|
self._log(f"VNC start request received (reason={reason}).")
|
||||||
|
self.vnc.start(port=port, allowed_ips=allowed_ips, password=password, reason=str(reason))
|
||||||
|
|
||||||
|
@sio.on("vnc_stop")
|
||||||
|
async def _vnc_stop(payload):
|
||||||
|
reason = "vnc_session_end"
|
||||||
|
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
|
||||||
|
self._log(f"VNC stop requested (reason={reason}).")
|
||||||
|
self.vnc.stop(reason=str(reason))
|
||||||
|
|
||||||
|
def stop_all(self) -> None:
|
||||||
|
try:
|
||||||
|
self.vnc.stop(reason="agent_shutdown")
|
||||||
|
except Exception:
|
||||||
|
self._log("Failed to stop VNC during shutdown.", error=True)
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
"""WireGuard client role (Windows) for reverse VPN tunnels.
|
"""WireGuard client role (Windows) for reverse VPN tunnels.
|
||||||
|
|
||||||
This role prepares the WireGuard client config, manages a single active
|
This role prepares the WireGuard client config, manages a single active
|
||||||
session, enforces idle teardown, and logs lifecycle events to
|
session, and keeps the tunnel online while the agent service runs. It logs
|
||||||
Agent/Logs/VPN_Tunnel/tunnel.log. It binds to Engine Socket.IO events
|
lifecycle events to Agent/Logs/VPN_Tunnel/tunnel.log. It responds to Engine
|
||||||
(`vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`) to start/stop
|
Socket.IO events (`vpn_tunnel_start`, `vpn_tunnel_activity`) and periodically
|
||||||
the client session with the issued config/token.
|
ensures the persistent session via `/api/agent/vpn/ensure`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -55,6 +55,24 @@ TUNNEL_IDLE_ADDRESS = "169.254.255.254/32"
|
|||||||
FIREWALL_RULE_NAME = "Borealis - WireGuard - Shell"
|
FIREWALL_RULE_NAME = "Borealis - WireGuard - Shell"
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, default: int, *, min_value: int = 1, max_value: int = 3600) -> int:
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
try:
|
||||||
|
value = int(raw) if raw is not None else default
|
||||||
|
except Exception:
|
||||||
|
value = default
|
||||||
|
if value < min_value:
|
||||||
|
return min_value
|
||||||
|
if value > max_value:
|
||||||
|
return max_value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
KEEPALIVE_SECONDS = _env_int("BOREALIS_WIREGUARD_KEEPALIVE_SECONDS", 30, min_value=10, max_value=600)
|
||||||
|
ENSURE_INITIAL_DELAY_SECONDS = _env_int("BOREALIS_WIREGUARD_ENSURE_DELAY", 10, min_value=0, max_value=300)
|
||||||
|
ENSURE_INTERVAL_SECONDS = _env_int("BOREALIS_WIREGUARD_ENSURE_INTERVAL", 60, min_value=15, max_value=3600)
|
||||||
|
|
||||||
|
|
||||||
def _log_path() -> Path:
|
def _log_path() -> Path:
|
||||||
root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
|
root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -69,6 +87,8 @@ def _write_log(message: str) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_key(raw: bytes) -> str:
|
def _encode_key(raw: bytes) -> str:
|
||||||
return base64.b64encode(raw).decode("ascii").strip()
|
return base64.b64encode(raw).decode("ascii").strip()
|
||||||
|
|
||||||
@@ -125,6 +145,7 @@ class SessionConfig:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
token: Dict[str, Any],
|
token: Dict[str, Any],
|
||||||
|
tunnel_id: str,
|
||||||
virtual_ip: str,
|
virtual_ip: str,
|
||||||
allowed_ips: str,
|
allowed_ips: str,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
@@ -136,6 +157,7 @@ class SessionConfig:
|
|||||||
client_public_key: Optional[str] = None,
|
client_public_key: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self.tunnel_id = tunnel_id
|
||||||
self.virtual_ip = virtual_ip
|
self.virtual_ip = virtual_ip
|
||||||
self.allowed_ips = allowed_ips
|
self.allowed_ips = allowed_ips
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
@@ -199,7 +221,7 @@ class WireGuardClient:
|
|||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_write_log(f"WireGuard service registry check failed: {exc}")
|
_write_log(f"WireGuard service registry check failed: {exc}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _service_image_path(self) -> Optional[str]:
|
def _service_image_path(self) -> Optional[str]:
|
||||||
if winreg is None:
|
if winreg is None:
|
||||||
@@ -223,6 +245,19 @@ class WireGuardClient:
|
|||||||
return Path(match.group(1))
|
return Path(match.group(1))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _service_state(self) -> Optional[str]:
|
||||||
|
code, out, err = self._run(["sc.exe", "query", self._service_id()])
|
||||||
|
if code != 0:
|
||||||
|
return None
|
||||||
|
text = out or err or ""
|
||||||
|
for line in text.splitlines():
|
||||||
|
if "STATE" not in line:
|
||||||
|
continue
|
||||||
|
match = re.search(r"STATE\s*:\s*\d+\s+(\w+)", line)
|
||||||
|
if match:
|
||||||
|
return match.group(1).upper()
|
||||||
|
return None
|
||||||
|
|
||||||
def _wireguard_config_path(self) -> Path:
|
def _wireguard_config_path(self) -> Path:
|
||||||
settings_dir = self.temp_root.parent / "Settings" / "WireGuard"
|
settings_dir = self.temp_root.parent / "Settings" / "WireGuard"
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -457,7 +492,7 @@ class WireGuardClient:
|
|||||||
f"PublicKey = {session.server_public_key}",
|
f"PublicKey = {session.server_public_key}",
|
||||||
f"AllowedIPs = {session.allowed_ips}",
|
f"AllowedIPs = {session.allowed_ips}",
|
||||||
f"Endpoint = {session.endpoint}",
|
f"Endpoint = {session.endpoint}",
|
||||||
"PersistentKeepalive = 20",
|
f"PersistentKeepalive = {KEEPALIVE_SECONDS}",
|
||||||
]
|
]
|
||||||
if session.preshared_key:
|
if session.preshared_key:
|
||||||
lines.append(f"PresharedKey = {session.preshared_key}")
|
lines.append(f"PresharedKey = {session.preshared_key}")
|
||||||
@@ -478,11 +513,45 @@ class WireGuardClient:
|
|||||||
t.start()
|
t.start()
|
||||||
self._idle_thread = t
|
self._idle_thread = t
|
||||||
|
|
||||||
|
def _stop_session_locked(self, reason: str = "stop", ignore_missing: bool = False) -> None:
|
||||||
|
self._remove_shell_firewall()
|
||||||
|
if not self._service_exists():
|
||||||
|
if not ignore_missing:
|
||||||
|
_write_log("WireGuard tunnel service not found when stopping session.")
|
||||||
|
self.session = None
|
||||||
|
self.idle_deadline = None
|
||||||
|
self._stop_event.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
idle_config = self._render_idle_config()
|
||||||
|
wrote_idle = self._write_config(idle_config)
|
||||||
|
service_config_path = self._service_config_path()
|
||||||
|
if service_config_path and service_config_path != self.conf_path:
|
||||||
|
wrote_idle = self._write_config_to(service_config_path, idle_config) or wrote_idle
|
||||||
|
if wrote_idle:
|
||||||
|
self._restart_service()
|
||||||
|
self._ensure_adapter_name()
|
||||||
|
self._ensure_service_display_name()
|
||||||
|
_write_log(f"WireGuard client session stopped (reason={reason}).")
|
||||||
|
elif not ignore_missing:
|
||||||
|
_write_log("Failed to write idle WireGuard config.")
|
||||||
|
self.session = None
|
||||||
|
self.idle_deadline = None
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
def start_session(self, session: SessionConfig, *, signing_client: Optional[Any] = None) -> None:
|
def start_session(self, session: SessionConfig, *, signing_client: Optional[Any] = None) -> None:
|
||||||
with self._session_lock:
|
with self._session_lock:
|
||||||
if self.session:
|
if self.session:
|
||||||
_write_log("Rejecting start_session: existing session already active.")
|
if self.session.tunnel_id == session.tunnel_id:
|
||||||
return
|
_write_log("WireGuard session already active; reusing existing session.")
|
||||||
|
self.bump_activity()
|
||||||
|
return
|
||||||
|
_write_log(
|
||||||
|
"WireGuard session replace: existing_tunnel_id={0} new_tunnel_id={1}".format(
|
||||||
|
self.session.tunnel_id, session.tunnel_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._stop_session_locked(reason="session_replace", ignore_missing=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._validate_token(session.token, signing_client=signing_client)
|
self._validate_token(session.token, signing_client=signing_client)
|
||||||
@@ -519,41 +588,15 @@ class WireGuardClient:
|
|||||||
self._ensure_shell_firewall(session.allowed_ips)
|
self._ensure_shell_firewall(session.allowed_ips)
|
||||||
|
|
||||||
self.session = session
|
self.session = session
|
||||||
self.idle_deadline = time.time() + max(60, session.idle_seconds)
|
self.idle_deadline = None
|
||||||
_write_log("WireGuard client session started; idle timer armed.")
|
_write_log("WireGuard client session started (persistent mode).")
|
||||||
self._start_idle_monitor()
|
|
||||||
|
|
||||||
def stop_session(self, reason: str = "stop", ignore_missing: bool = False) -> None:
|
def stop_session(self, reason: str = "stop", ignore_missing: bool = False) -> None:
|
||||||
with self._session_lock:
|
with self._session_lock:
|
||||||
self._remove_shell_firewall()
|
self._stop_session_locked(reason=reason, ignore_missing=ignore_missing)
|
||||||
if not self._service_exists():
|
|
||||||
if not ignore_missing:
|
|
||||||
_write_log("WireGuard tunnel service not found when stopping session.")
|
|
||||||
self.session = None
|
|
||||||
self.idle_deadline = None
|
|
||||||
self._stop_event.set()
|
|
||||||
return
|
|
||||||
|
|
||||||
idle_config = self._render_idle_config()
|
|
||||||
wrote_idle = self._write_config(idle_config)
|
|
||||||
service_config_path = self._service_config_path()
|
|
||||||
if service_config_path and service_config_path != self.conf_path:
|
|
||||||
wrote_idle = self._write_config_to(service_config_path, idle_config) or wrote_idle
|
|
||||||
if wrote_idle:
|
|
||||||
self._restart_service()
|
|
||||||
self._ensure_adapter_name()
|
|
||||||
self._ensure_service_display_name()
|
|
||||||
_write_log(f"WireGuard client session stopped (reason={reason}).")
|
|
||||||
elif not ignore_missing:
|
|
||||||
_write_log("Failed to write idle WireGuard config.")
|
|
||||||
self.session = None
|
|
||||||
self.idle_deadline = None
|
|
||||||
self._stop_event.set()
|
|
||||||
|
|
||||||
def bump_activity(self) -> None:
|
def bump_activity(self) -> None:
|
||||||
if self.session and self.idle_deadline:
|
return
|
||||||
self.idle_deadline = time.time() + max(60, self.session.idle_seconds)
|
|
||||||
_write_log("WireGuard client activity bump; idle timer reset.")
|
|
||||||
|
|
||||||
|
|
||||||
_client: Optional[WireGuardClient] = None
|
_client: Optional[WireGuardClient] = None
|
||||||
@@ -686,10 +729,9 @@ class Role:
|
|||||||
self._log_hook = hooks.get("log_agent")
|
self._log_hook = hooks.get("log_agent")
|
||||||
self._http_client_factory = hooks.get("http_client")
|
self._http_client_factory = hooks.get("http_client")
|
||||||
self._get_server_url = hooks.get("get_server_url")
|
self._get_server_url = hooks.get("get_server_url")
|
||||||
try:
|
self._ensure_stop = threading.Event()
|
||||||
self.client.stop_session(reason="agent_startup", ignore_missing=True)
|
self._ensure_thread = threading.Thread(target=self._ensure_loop, daemon=True)
|
||||||
except Exception:
|
self._ensure_thread.start()
|
||||||
self._log("Failed to preflight WireGuard session cleanup.", error=True)
|
|
||||||
|
|
||||||
def _log(self, message: str, *, error: bool = False) -> None:
|
def _log(self, message: str, *, error: bool = False) -> None:
|
||||||
if callable(self._log_hook):
|
if callable(self._log_hook):
|
||||||
@@ -751,6 +793,11 @@ class Role:
|
|||||||
self._log("WireGuard start missing token payload.", error=True)
|
self._log("WireGuard start missing token payload.", error=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
tunnel_id = payload.get("tunnel_id") or token.get("tunnel_id")
|
||||||
|
if not tunnel_id:
|
||||||
|
self._log("WireGuard start missing tunnel_id.", error=True)
|
||||||
|
return None
|
||||||
|
|
||||||
virtual_ip = payload.get("virtual_ip") or payload.get("client_virtual_ip")
|
virtual_ip = payload.get("virtual_ip") or payload.get("client_virtual_ip")
|
||||||
endpoint = payload.get("endpoint") or payload.get("server_endpoint")
|
endpoint = payload.get("endpoint") or payload.get("server_endpoint")
|
||||||
endpoint = self._resolve_endpoint(endpoint, token)
|
endpoint = self._resolve_endpoint(endpoint, token)
|
||||||
@@ -780,6 +827,7 @@ class Role:
|
|||||||
|
|
||||||
return SessionConfig(
|
return SessionConfig(
|
||||||
token=token,
|
token=token,
|
||||||
|
tunnel_id=str(tunnel_id),
|
||||||
virtual_ip=str(virtual_ip),
|
virtual_ip=str(virtual_ip),
|
||||||
allowed_ips=str(allowed_ips),
|
allowed_ips=str(allowed_ips),
|
||||||
endpoint=str(endpoint),
|
endpoint=str(endpoint),
|
||||||
@@ -791,6 +839,51 @@ class Role:
|
|||||||
client_public_key=payload.get("client_public_key"),
|
client_public_key=payload.get("client_public_key"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _request_persistent_session(self) -> Optional[Dict[str, Any]]:
|
||||||
|
client = self._http_client()
|
||||||
|
if client is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = client.post_json(
|
||||||
|
"/api/agent/vpn/ensure",
|
||||||
|
{"agent_id": self.ctx.agent_id, "reason": "agent_boot"},
|
||||||
|
require_auth=True,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log(f"WireGuard ensure request failed: {exc}", error=True)
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _ensure_loop(self) -> None:
|
||||||
|
if ENSURE_INITIAL_DELAY_SECONDS:
|
||||||
|
time.sleep(ENSURE_INITIAL_DELAY_SECONDS)
|
||||||
|
while not self._ensure_stop.is_set():
|
||||||
|
payload = self._request_persistent_session()
|
||||||
|
if payload:
|
||||||
|
incoming_tunnel = str(payload.get("tunnel_id") or "")
|
||||||
|
current_tunnel = ""
|
||||||
|
if self.client.session is not None:
|
||||||
|
try:
|
||||||
|
current_tunnel = str(self.client.session.tunnel_id)
|
||||||
|
except Exception:
|
||||||
|
current_tunnel = ""
|
||||||
|
state = None
|
||||||
|
try:
|
||||||
|
state = self.client._service_state()
|
||||||
|
except Exception:
|
||||||
|
state = None
|
||||||
|
service_ready = state in ("RUNNING", "START_PENDING")
|
||||||
|
if incoming_tunnel and incoming_tunnel == current_tunnel and service_ready:
|
||||||
|
self._ensure_stop.wait(ENSURE_INTERVAL_SECONDS)
|
||||||
|
continue
|
||||||
|
session = self._build_session(payload)
|
||||||
|
if session:
|
||||||
|
self._log("WireGuard persistent session ensure received.")
|
||||||
|
self.client.start_session(session, signing_client=self._http_client())
|
||||||
|
self._ensure_stop.wait(ENSURE_INTERVAL_SECONDS)
|
||||||
|
|
||||||
def register_events(self) -> None:
|
def register_events(self) -> None:
|
||||||
sio = self.ctx.sio
|
sio = self.ctx.sio
|
||||||
|
|
||||||
@@ -810,14 +903,18 @@ class Role:
|
|||||||
if target_agent and str(target_agent).strip() != str(self.ctx.agent_id).strip():
|
if target_agent and str(target_agent).strip() != str(self.ctx.agent_id).strip():
|
||||||
return
|
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}); persistent tunnels ignore stop.")
|
||||||
self.client.stop_session(reason=str(reason))
|
|
||||||
|
|
||||||
@sio.on("vpn_tunnel_activity")
|
@sio.on("vpn_tunnel_activity")
|
||||||
async def _vpn_tunnel_activity(payload):
|
async def _vpn_tunnel_activity(payload):
|
||||||
self.client.bump_activity()
|
self.client.bump_activity()
|
||||||
|
|
||||||
def stop_all(self) -> None:
|
def stop_all(self) -> None:
|
||||||
|
try:
|
||||||
|
self._ensure_stop.set()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
self.client.stop_session(reason="agent_shutdown")
|
self.client.stop_session(reason="agent_shutdown")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -81,14 +81,19 @@ VPN_TUNNEL_LOG_FILE_PATH = LOG_ROOT / "VPN_Tunnel" / "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/16"
|
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"
|
VPN_SERVER_CERT_ROOT = PROJECT_ROOT / "Engine" / "Certificates" / "VPN_Server"
|
||||||
DEFAULT_GUACD_HOST = "127.0.0.1"
|
DEFAULT_VNC_PORT = 5900
|
||||||
DEFAULT_GUACD_PORT = 4822
|
DEFAULT_WIREGUARD_SHELL_PORT = 47002
|
||||||
DEFAULT_RDP_WS_HOST = "0.0.0.0"
|
DEFAULT_WIREGUARD_ACL_WINDOWS = (
|
||||||
DEFAULT_RDP_WS_PORT = 4823
|
5985,
|
||||||
DEFAULT_RDP_SESSION_TTL_SECONDS = 120
|
5986,
|
||||||
|
5900,
|
||||||
|
3478,
|
||||||
|
DEFAULT_WIREGUARD_SHELL_PORT,
|
||||||
|
)
|
||||||
|
DEFAULT_VNC_WS_HOST = "0.0.0.0"
|
||||||
|
DEFAULT_VNC_WS_PORT = 4823
|
||||||
|
DEFAULT_VNC_SESSION_TTL_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
def _ensure_parent(path: Path) -> None:
|
def _ensure_parent(path: Path) -> None:
|
||||||
@@ -290,11 +295,10 @@ class EngineSettings:
|
|||||||
wireguard_server_public_key_path: str
|
wireguard_server_public_key_path: str
|
||||||
wireguard_acl_allowlist_windows: Tuple[int, ...]
|
wireguard_acl_allowlist_windows: Tuple[int, ...]
|
||||||
wireguard_shell_port: int
|
wireguard_shell_port: int
|
||||||
guacd_host: str
|
vnc_port: int
|
||||||
guacd_port: int
|
vnc_ws_host: str
|
||||||
rdp_ws_host: str
|
vnc_ws_port: int
|
||||||
rdp_ws_port: int
|
vnc_session_ttl_seconds: int
|
||||||
rdp_session_ttl_seconds: int
|
|
||||||
raw: MutableMapping[str, Any] = field(default_factory=dict)
|
raw: MutableMapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def to_flask_config(self) -> MutableMapping[str, Any]:
|
def to_flask_config(self) -> MutableMapping[str, Any]:
|
||||||
@@ -437,32 +441,27 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine
|
|||||||
wireguard_server_private_key_path = str(wireguard_key_root / "server_private.key")
|
wireguard_server_private_key_path = str(wireguard_key_root / "server_private.key")
|
||||||
wireguard_server_public_key_path = str(wireguard_key_root / "server_public.key")
|
wireguard_server_public_key_path = str(wireguard_key_root / "server_public.key")
|
||||||
|
|
||||||
guacd_host = str(
|
vnc_port = _parse_int(
|
||||||
runtime_config.get("GUACD_HOST")
|
runtime_config.get("VNC_PORT") or os.environ.get("BOREALIS_VNC_PORT"),
|
||||||
or os.environ.get("BOREALIS_GUACD_HOST")
|
default=DEFAULT_VNC_PORT,
|
||||||
or DEFAULT_GUACD_HOST
|
|
||||||
)
|
|
||||||
guacd_port = _parse_int(
|
|
||||||
runtime_config.get("GUACD_PORT") or os.environ.get("BOREALIS_GUACD_PORT"),
|
|
||||||
default=DEFAULT_GUACD_PORT,
|
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=65535,
|
maximum=65535,
|
||||||
)
|
)
|
||||||
rdp_ws_host = str(
|
vnc_ws_host = str(
|
||||||
runtime_config.get("RDP_WS_HOST")
|
runtime_config.get("VNC_WS_HOST")
|
||||||
or os.environ.get("BOREALIS_RDP_WS_HOST")
|
or os.environ.get("BOREALIS_VNC_WS_HOST")
|
||||||
or DEFAULT_RDP_WS_HOST
|
or DEFAULT_VNC_WS_HOST
|
||||||
)
|
)
|
||||||
rdp_ws_port = _parse_int(
|
vnc_ws_port = _parse_int(
|
||||||
runtime_config.get("RDP_WS_PORT") or os.environ.get("BOREALIS_RDP_WS_PORT"),
|
runtime_config.get("VNC_WS_PORT") or os.environ.get("BOREALIS_VNC_WS_PORT"),
|
||||||
default=DEFAULT_RDP_WS_PORT,
|
default=DEFAULT_VNC_WS_PORT,
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=65535,
|
maximum=65535,
|
||||||
)
|
)
|
||||||
rdp_session_ttl_seconds = _parse_int(
|
vnc_session_ttl_seconds = _parse_int(
|
||||||
runtime_config.get("RDP_SESSION_TTL_SECONDS")
|
runtime_config.get("VNC_SESSION_TTL_SECONDS")
|
||||||
or os.environ.get("BOREALIS_RDP_SESSION_TTL_SECONDS"),
|
or os.environ.get("BOREALIS_VNC_SESSION_TTL_SECONDS"),
|
||||||
default=DEFAULT_RDP_SESSION_TTL_SECONDS,
|
default=DEFAULT_VNC_SESSION_TTL_SECONDS,
|
||||||
minimum=30,
|
minimum=30,
|
||||||
maximum=3600,
|
maximum=3600,
|
||||||
)
|
)
|
||||||
@@ -505,11 +504,10 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine
|
|||||||
wireguard_server_public_key_path=wireguard_server_public_key_path,
|
wireguard_server_public_key_path=wireguard_server_public_key_path,
|
||||||
wireguard_acl_allowlist_windows=wireguard_acl_allowlist_windows,
|
wireguard_acl_allowlist_windows=wireguard_acl_allowlist_windows,
|
||||||
wireguard_shell_port=wireguard_shell_port,
|
wireguard_shell_port=wireguard_shell_port,
|
||||||
guacd_host=guacd_host,
|
vnc_port=vnc_port,
|
||||||
guacd_port=guacd_port,
|
vnc_ws_host=vnc_ws_host,
|
||||||
rdp_ws_host=rdp_ws_host,
|
vnc_ws_port=vnc_ws_port,
|
||||||
rdp_ws_port=rdp_ws_port,
|
vnc_session_ttl_seconds=vnc_session_ttl_seconds,
|
||||||
rdp_session_ttl_seconds=rdp_session_ttl_seconds,
|
|
||||||
raw=runtime_config,
|
raw=runtime_config,
|
||||||
)
|
)
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ def _ensure_devices_table(conn: sqlite3.Connection) -> None:
|
|||||||
"ansible_ee_ver": "TEXT",
|
"ansible_ee_ver": "TEXT",
|
||||||
"connection_type": "TEXT",
|
"connection_type": "TEXT",
|
||||||
"connection_endpoint": "TEXT",
|
"connection_endpoint": "TEXT",
|
||||||
|
"agent_vnc_password": "TEXT",
|
||||||
"ssl_key_fingerprint": "TEXT",
|
"ssl_key_fingerprint": "TEXT",
|
||||||
"token_version": "INTEGER",
|
"token_version": "INTEGER",
|
||||||
"status": "TEXT",
|
"status": "TEXT",
|
||||||
@@ -388,6 +389,7 @@ def _create_devices_table(cur: sqlite3.Cursor) -> None:
|
|||||||
ansible_ee_ver TEXT,
|
ansible_ee_ver TEXT,
|
||||||
connection_type TEXT,
|
connection_type TEXT,
|
||||||
connection_endpoint TEXT,
|
connection_endpoint TEXT,
|
||||||
|
agent_vnc_password TEXT,
|
||||||
ssl_key_fingerprint TEXT,
|
ssl_key_fingerprint TEXT,
|
||||||
token_version INTEGER DEFAULT 1,
|
token_version INTEGER DEFAULT 1,
|
||||||
status TEXT DEFAULT 'active',
|
status TEXT DEFAULT 'active',
|
||||||
@@ -455,9 +457,9 @@ def _rebuild_devices_table(conn: sqlite3.Connection, column_info: Sequence[Tuple
|
|||||||
network, software, storage, cpu, device_type, domain, external_ip,
|
network, software, storage, cpu, device_type, domain, external_ip,
|
||||||
internal_ip, last_reboot, last_seen, last_user, operating_system,
|
internal_ip, last_reboot, last_seen, last_user, operating_system,
|
||||||
uptime, agent_id, ansible_ee_ver, connection_type, connection_endpoint,
|
uptime, agent_id, ansible_ee_ver, connection_type, connection_endpoint,
|
||||||
ssl_key_fingerprint, token_version, status, key_added_at
|
agent_vnc_password, ssl_key_fingerprint, token_version, status, key_added_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -496,6 +498,7 @@ def _rebuild_devices_table(conn: sqlite3.Connection, column_info: Sequence[Tuple
|
|||||||
record.get("ansible_ee_ver"),
|
record.get("ansible_ee_ver"),
|
||||||
record.get("connection_type"),
|
record.get("connection_type"),
|
||||||
record.get("connection_endpoint"),
|
record.get("connection_endpoint"),
|
||||||
|
record.get("agent_vnc_password"),
|
||||||
record.get("ssl_key_fingerprint"),
|
record.get("ssl_key_fingerprint"),
|
||||||
record.get("token_version") or 1,
|
record.get("token_version") or 1,
|
||||||
record.get("status") or "active",
|
record.get("status") or "active",
|
||||||
|
|||||||
@@ -128,15 +128,14 @@ class EngineContext:
|
|||||||
wireguard_server_public_key_path: str
|
wireguard_server_public_key_path: str
|
||||||
wireguard_acl_allowlist_windows: Tuple[int, ...]
|
wireguard_acl_allowlist_windows: Tuple[int, ...]
|
||||||
wireguard_shell_port: int
|
wireguard_shell_port: int
|
||||||
guacd_host: str
|
vnc_port: int
|
||||||
guacd_port: int
|
vnc_ws_host: str
|
||||||
rdp_ws_host: str
|
vnc_ws_port: int
|
||||||
rdp_ws_port: int
|
vnc_session_ttl_seconds: int
|
||||||
rdp_session_ttl_seconds: int
|
|
||||||
wireguard_server_manager: Optional[Any] = None
|
wireguard_server_manager: Optional[Any] = None
|
||||||
assembly_cache: Optional[Any] = None
|
assembly_cache: Optional[Any] = None
|
||||||
rdp_proxy: Optional[Any] = None
|
vnc_proxy: Optional[Any] = None
|
||||||
rdp_registry: Optional[Any] = None
|
vnc_registry: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["EngineContext", "create_app", "register_engine_api"]
|
__all__ = ["EngineContext", "create_app", "register_engine_api"]
|
||||||
@@ -162,11 +161,10 @@ def _build_engine_context(settings: EngineSettings, logger: logging.Logger) -> E
|
|||||||
wireguard_server_public_key_path=settings.wireguard_server_public_key_path,
|
wireguard_server_public_key_path=settings.wireguard_server_public_key_path,
|
||||||
wireguard_acl_allowlist_windows=settings.wireguard_acl_allowlist_windows,
|
wireguard_acl_allowlist_windows=settings.wireguard_acl_allowlist_windows,
|
||||||
wireguard_shell_port=settings.wireguard_shell_port,
|
wireguard_shell_port=settings.wireguard_shell_port,
|
||||||
guacd_host=settings.guacd_host,
|
vnc_port=settings.vnc_port,
|
||||||
guacd_port=settings.guacd_port,
|
vnc_ws_host=settings.vnc_ws_host,
|
||||||
rdp_ws_host=settings.rdp_ws_host,
|
vnc_ws_port=settings.vnc_ws_port,
|
||||||
rdp_ws_port=settings.rdp_ws_port,
|
vnc_session_ttl_seconds=settings.vnc_session_ttl_seconds,
|
||||||
rdp_session_ttl_seconds=settings.rdp_session_ttl_seconds,
|
|
||||||
assembly_cache=None,
|
assembly_cache=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ from ..auth import DevModeManager
|
|||||||
from .enrollment import routes as enrollment_routes
|
from .enrollment import routes as enrollment_routes
|
||||||
from .tokens import routes as token_routes
|
from .tokens import routes as token_routes
|
||||||
from .devices.tunnel import register_tunnel
|
from .devices.tunnel import register_tunnel
|
||||||
from .devices.rdp import register_rdp
|
from .devices.vnc import register_vnc
|
||||||
|
from .devices.shell import register_shell
|
||||||
|
|
||||||
from ...server import EngineContext
|
from ...server import EngineContext
|
||||||
from .access_management.login import register_auth
|
from .access_management.login import register_auth
|
||||||
@@ -292,7 +293,8 @@ def _register_devices(app: Flask, adapters: EngineServiceAdapters) -> None:
|
|||||||
register_admin_endpoints(app, adapters)
|
register_admin_endpoints(app, adapters)
|
||||||
device_routes.register_agents(app, adapters)
|
device_routes.register_agents(app, adapters)
|
||||||
register_tunnel(app, adapters)
|
register_tunnel(app, adapters)
|
||||||
register_rdp(app, adapters)
|
register_vnc(app, adapters)
|
||||||
|
register_shell(app, adapters)
|
||||||
|
|
||||||
def _register_filters(app: Flask, adapters: EngineServiceAdapters) -> None:
|
def _register_filters(app: Flask, adapters: EngineServiceAdapters) -> None:
|
||||||
filters_management.register_filters(app, adapters)
|
filters_management.register_filters(app, adapters)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
# API Endpoints (if applicable):
|
# API Endpoints (if applicable):
|
||||||
# - POST /api/agent/heartbeat (Device Authenticated) - Updates device last-seen metadata and inventory snapshots.
|
# - POST /api/agent/heartbeat (Device Authenticated) - Updates device last-seen metadata and inventory snapshots.
|
||||||
# - POST /api/agent/script/request (Device Authenticated) - Provides script execution payloads or idle signals to agents.
|
# - POST /api/agent/script/request (Device Authenticated) - Provides script execution payloads or idle signals to agents.
|
||||||
|
# - POST /api/agent/vpn/ensure (Device Authenticated) - Ensures persistent WireGuard tunnel material.
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
||||||
"""Device-affiliated agent endpoints for the Borealis Engine runtime."""
|
"""Device-affiliated agent endpoints for the Borealis Engine runtime."""
|
||||||
@@ -13,12 +14,14 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import urlsplit
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, g
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
|
||||||
from ....auth.device_auth import AGENT_CONTEXT_HEADER, require_device_auth
|
from ....auth.device_auth import AGENT_CONTEXT_HEADER, require_device_auth
|
||||||
from ....auth.guid_utils import normalize_guid
|
from ....auth.guid_utils import normalize_guid
|
||||||
|
from .tunnel import _get_tunnel_service
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover - typing aide
|
if TYPE_CHECKING: # pragma: no cover - typing aide
|
||||||
from .. import EngineServiceAdapters
|
from .. import EngineServiceAdapters
|
||||||
@@ -42,6 +45,20 @@ def _json_or_none(value: Any) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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_agents(app, adapters: "EngineServiceAdapters") -> None:
|
def register_agents(app, adapters: "EngineServiceAdapters") -> None:
|
||||||
"""Register agent heartbeat and script polling routes."""
|
"""Register agent heartbeat and script polling routes."""
|
||||||
|
|
||||||
@@ -218,4 +235,76 @@ def register_agents(app, adapters: "EngineServiceAdapters") -> None:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@blueprint.route("/api/agent/vpn/ensure", methods=["POST"])
|
||||||
|
@require_device_auth(auth_manager)
|
||||||
|
def vpn_ensure():
|
||||||
|
ctx = _auth_context()
|
||||||
|
if ctx is None:
|
||||||
|
return jsonify({"error": "auth_context_missing"}), 500
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
requested_agent = (body.get("agent_id") or "").strip()
|
||||||
|
guid = normalize_guid(ctx.guid)
|
||||||
|
|
||||||
|
conn = db_conn_factory()
|
||||||
|
resolved_agent = ""
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT agent_id FROM devices WHERE UPPER(guid) = ? ORDER BY last_seen DESC LIMIT 1",
|
||||||
|
(guid,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
resolved_agent = str(row[0]).strip()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not resolved_agent:
|
||||||
|
log("VPN_Tunnel/tunnel", f"vpn_agent_ensure_missing_agent guid={guid}", _context_hint(ctx), level="ERROR")
|
||||||
|
return jsonify({"error": "agent_id_missing"}), 404
|
||||||
|
|
||||||
|
if requested_agent and requested_agent != resolved_agent:
|
||||||
|
log(
|
||||||
|
"VPN_Tunnel/tunnel",
|
||||||
|
"vpn_agent_ensure_agent_mismatch requested={0} resolved={1}".format(
|
||||||
|
requested_agent, resolved_agent
|
||||||
|
),
|
||||||
|
_context_hint(ctx),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tunnel_service = _get_tunnel_service(adapters)
|
||||||
|
endpoint_host = _infer_endpoint_host(request)
|
||||||
|
log(
|
||||||
|
"VPN_Tunnel/tunnel",
|
||||||
|
"vpn_agent_ensure_request agent_id={0} endpoint_host={1}".format(
|
||||||
|
resolved_agent, endpoint_host or "-"
|
||||||
|
),
|
||||||
|
_context_hint(ctx),
|
||||||
|
)
|
||||||
|
payload = tunnel_service.connect(
|
||||||
|
agent_id=resolved_agent,
|
||||||
|
operator_id=None,
|
||||||
|
endpoint_host=endpoint_host,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log(
|
||||||
|
"VPN_Tunnel/tunnel",
|
||||||
|
"vpn_agent_ensure_failed agent_id={0} error={1}".format(resolved_agent, str(exc)),
|
||||||
|
_context_hint(ctx),
|
||||||
|
level="ERROR",
|
||||||
|
)
|
||||||
|
return jsonify({"error": "tunnel_start_failed", "detail": str(exc)}), 500
|
||||||
|
|
||||||
|
log(
|
||||||
|
"VPN_Tunnel/tunnel",
|
||||||
|
"vpn_agent_ensure_response agent_id={0} tunnel_id={1}".format(
|
||||||
|
payload.get("agent_id", resolved_agent), payload.get("tunnel_id", "-")
|
||||||
|
),
|
||||||
|
_context_hint(ctx),
|
||||||
|
)
|
||||||
|
return jsonify(payload), 200
|
||||||
|
|
||||||
app.register_blueprint(blueprint)
|
app.register_blueprint(blueprint)
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# ======================================================
|
# ======================================================
|
||||||
# Data\Engine\services\API\devices\rdp.py
|
# Data\Engine\services\API\devices\shell.py
|
||||||
# Description: RDP session bootstrap for Guacamole WebSocket tunnels.
|
# Description: Remote PowerShell session endpoints for persistent WireGuard tunnels.
|
||||||
#
|
#
|
||||||
# API Endpoints (if applicable):
|
# API Endpoints (if applicable):
|
||||||
# - POST /api/rdp/session (Token Authenticated) - Issues a one-time Guacamole tunnel token for RDP.
|
# - POST /api/shell/establish (Token Authenticated) - Ensure shell readiness over WireGuard.
|
||||||
|
# - POST /api/shell/disconnect (Token Authenticated) - Disconnect the operator shell session.
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
||||||
"""RDP session bootstrap endpoints for the Borealis Engine."""
|
"""Remote PowerShell session endpoints for the Borealis Engine."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -16,7 +17,6 @@ from urllib.parse import urlsplit
|
|||||||
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 ...RemoteDesktop.guacamole_proxy import GUAC_WS_PATH, ensure_guacamole_proxy
|
|
||||||
from .tunnel import _get_tunnel_service
|
from .tunnel import _get_tunnel_service
|
||||||
|
|
||||||
if False: # pragma: no cover - hint for type checkers
|
if False: # pragma: no cover - hint for type checkers
|
||||||
@@ -81,25 +81,18 @@ def _infer_endpoint_host(req) -> str:
|
|||||||
return host
|
return host
|
||||||
|
|
||||||
|
|
||||||
def _is_secure(req) -> bool:
|
def register_shell(app, adapters: "EngineServiceAdapters") -> None:
|
||||||
if req.is_secure:
|
blueprint = Blueprint("vpn_shell", __name__)
|
||||||
return True
|
logger = adapters.context.logger.getChild("vpn_shell.api")
|
||||||
forwarded = (req.headers.get("X-Forwarded-Proto") or "").split(",")[0].strip().lower()
|
|
||||||
return forwarded == "https"
|
|
||||||
|
|
||||||
|
|
||||||
def register_rdp(app, adapters: "EngineServiceAdapters") -> None:
|
|
||||||
blueprint = Blueprint("rdp", __name__)
|
|
||||||
logger = adapters.context.logger.getChild("rdp.api")
|
|
||||||
service_log = adapters.service_log
|
service_log = adapters.service_log
|
||||||
|
|
||||||
def _service_log_event(message: str, *, level: str = "INFO") -> None:
|
def _service_log_event(message: str, *, level: str = "INFO") -> None:
|
||||||
if not callable(service_log):
|
if not callable(service_log):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
service_log("RDP", message, level=level)
|
service_log("VPN_Tunnel/remote_shell", message, level=level)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("rdp service log write failed", exc_info=True)
|
logger.debug("vpn_shell service log write failed", exc_info=True)
|
||||||
|
|
||||||
def _request_remote() -> str:
|
def _request_remote() -> str:
|
||||||
forwarded = (request.headers.get("X-Forwarded-For") or "").strip()
|
forwarded = (request.headers.get("X-Forwarded-For") or "").strip()
|
||||||
@@ -107,8 +100,8 @@ def register_rdp(app, adapters: "EngineServiceAdapters") -> None:
|
|||||||
return forwarded.split(",")[0].strip()
|
return forwarded.split(",")[0].strip()
|
||||||
return (request.remote_addr or "").strip()
|
return (request.remote_addr or "").strip()
|
||||||
|
|
||||||
@blueprint.route("/api/rdp/session", methods=["POST"])
|
@blueprint.route("/api/shell/establish", methods=["POST"])
|
||||||
def rdp_session():
|
def shell_establish():
|
||||||
requirement = _require_login(app)
|
requirement = _require_login(app)
|
||||||
if requirement:
|
if requirement:
|
||||||
payload, status = requirement
|
payload, status = requirement
|
||||||
@@ -119,77 +112,82 @@ def register_rdp(app, adapters: "EngineServiceAdapters") -> None:
|
|||||||
|
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
agent_id = _normalize_text(body.get("agent_id"))
|
agent_id = _normalize_text(body.get("agent_id"))
|
||||||
protocol = _normalize_text(body.get("protocol") or "rdp").lower()
|
if not agent_id:
|
||||||
username = _normalize_text(body.get("username"))
|
return jsonify({"error": "agent_id_required"}), 400
|
||||||
password = str(body.get("password") or "")
|
|
||||||
|
try:
|
||||||
|
tunnel_service = _get_tunnel_service(adapters)
|
||||||
|
endpoint_host = _infer_endpoint_host(request)
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_shell_establish_request agent_id={0} operator={1} endpoint_host={2} remote={3}".format(
|
||||||
|
agent_id,
|
||||||
|
operator_id or "-",
|
||||||
|
endpoint_host or "-",
|
||||||
|
_request_remote() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
payload = tunnel_service.connect(
|
||||||
|
agent_id=agent_id,
|
||||||
|
operator_id=operator_id,
|
||||||
|
endpoint_host=endpoint_host,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_shell_establish_failed agent_id={0} operator={1} error={2}".format(
|
||||||
|
agent_id,
|
||||||
|
operator_id or "-",
|
||||||
|
str(exc),
|
||||||
|
),
|
||||||
|
level="ERROR",
|
||||||
|
)
|
||||||
|
return jsonify({"error": "establish_failed", "detail": str(exc)}), 500
|
||||||
|
|
||||||
|
agent_socket = False
|
||||||
|
registry = getattr(adapters.context, "agent_socket_registry", None)
|
||||||
|
if registry and hasattr(registry, "is_registered"):
|
||||||
|
try:
|
||||||
|
agent_socket = bool(registry.is_registered(agent_id))
|
||||||
|
except Exception:
|
||||||
|
agent_socket = False
|
||||||
|
|
||||||
|
response = dict(payload)
|
||||||
|
response["status"] = "ok"
|
||||||
|
response["agent_socket"] = agent_socket
|
||||||
|
_service_log_event(
|
||||||
|
"vpn_shell_establish_response agent_id={0} tunnel_id={1} agent_socket={2}".format(
|
||||||
|
agent_id,
|
||||||
|
response.get("tunnel_id", "-"),
|
||||||
|
str(agent_socket).lower(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify(response), 200
|
||||||
|
|
||||||
|
@blueprint.route("/api/shell/disconnect", methods=["POST"])
|
||||||
|
def shell_disconnect():
|
||||||
|
requirement = _require_login(app)
|
||||||
|
if requirement:
|
||||||
|
payload, status = requirement
|
||||||
|
return jsonify(payload), status
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
agent_id = _normalize_text(body.get("agent_id"))
|
||||||
|
reason = _normalize_text(body.get("reason") or "operator_disconnect")
|
||||||
|
operator_id = (_current_user(app) or {}).get("username") or None
|
||||||
|
|
||||||
if not agent_id:
|
if not agent_id:
|
||||||
return jsonify({"error": "agent_id_required"}), 400
|
return jsonify({"error": "agent_id_required"}), 400
|
||||||
if protocol != "rdp":
|
|
||||||
return jsonify({"error": "unsupported_protocol"}), 400
|
|
||||||
|
|
||||||
tunnel_service = _get_tunnel_service(adapters)
|
|
||||||
session_payload = tunnel_service.session_payload(agent_id, include_token=False)
|
|
||||||
if not session_payload:
|
|
||||||
return jsonify({"error": "tunnel_down"}), 409
|
|
||||||
|
|
||||||
allowed_ports = session_payload.get("allowed_ports") or []
|
|
||||||
if 3389 not in allowed_ports:
|
|
||||||
return jsonify({"error": "rdp_not_allowed"}), 403
|
|
||||||
|
|
||||||
virtual_ip = _normalize_text(session_payload.get("virtual_ip"))
|
|
||||||
host = virtual_ip.split("/")[0] if virtual_ip else ""
|
|
||||||
if not host:
|
|
||||||
return jsonify({"error": "virtual_ip_missing"}), 500
|
|
||||||
|
|
||||||
registry = ensure_guacamole_proxy(adapters.context, logger=logger)
|
|
||||||
if registry is None:
|
|
||||||
return jsonify({"error": "rdp_proxy_unavailable"}), 503
|
|
||||||
|
|
||||||
_service_log_event(
|
_service_log_event(
|
||||||
"rdp_session_request agent_id={0} operator={1} protocol={2} remote={3}".format(
|
"vpn_shell_disconnect_request agent_id={0} operator={1} reason={2} remote={3}".format(
|
||||||
agent_id,
|
agent_id,
|
||||||
operator_id or "-",
|
operator_id or "-",
|
||||||
protocol,
|
reason or "-",
|
||||||
_request_remote() or "-",
|
_request_remote() or "-",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
return jsonify({"status": "disconnected", "reason": reason}), 200
|
||||||
rdp_session = registry.create(
|
|
||||||
agent_id=agent_id,
|
|
||||||
host=host,
|
|
||||||
port=3389,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
protocol=protocol,
|
|
||||||
operator_id=operator_id,
|
|
||||||
ignore_cert=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
ws_scheme = "wss" if _is_secure(request) else "ws"
|
|
||||||
ws_host = _infer_endpoint_host(request)
|
|
||||||
ws_port = int(getattr(adapters.context, "rdp_ws_port", 4823))
|
|
||||||
ws_url = f"{ws_scheme}://{ws_host}:{ws_port}{GUAC_WS_PATH}"
|
|
||||||
|
|
||||||
_service_log_event(
|
|
||||||
"rdp_session_ready agent_id={0} token={1} expires_at={2}".format(
|
|
||||||
agent_id,
|
|
||||||
rdp_session.token[:8],
|
|
||||||
int(rdp_session.expires_at),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
jsonify(
|
|
||||||
{
|
|
||||||
"token": rdp_session.token,
|
|
||||||
"ws_url": ws_url,
|
|
||||||
"expires_at": int(rdp_session.expires_at),
|
|
||||||
"virtual_ip": host,
|
|
||||||
"tunnel_id": session_payload.get("tunnel_id"),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.register_blueprint(blueprint)
|
app.register_blueprint(blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["register_shell"]
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
# ======================================================
|
# ======================================================
|
||||||
# Data\Engine\services\API\devices\tunnel.py
|
# Data\Engine\services\API\devices\tunnel.py
|
||||||
# Description: WireGuard VPN tunnel API (connect/status/disconnect).
|
# Description: WireGuard VPN tunnel API (connect/status).
|
||||||
#
|
#
|
||||||
# API Endpoints (if applicable):
|
# API Endpoints (if applicable):
|
||||||
# - POST /api/tunnel/connect (Token Authenticated) - Issues VPN session material for an agent.
|
# - POST /api/tunnel/connect (Token Authenticated) - Issues VPN session material for an agent.
|
||||||
# - GET /api/tunnel/status (Token Authenticated) - Returns VPN status for an agent.
|
# - GET /api/tunnel/status (Token Authenticated) - Returns VPN status for an agent.
|
||||||
# - GET /api/tunnel/active (Token Authenticated) - Lists active VPN tunnel sessions.
|
# - GET /api/tunnel/active (Token Authenticated) - Lists active VPN tunnel sessions.
|
||||||
# - DELETE /api/tunnel/disconnect (Token Authenticated) - Tears down VPN session for an agent.
|
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
||||||
"""WireGuard VPN tunnel API (Engine side)."""
|
"""WireGuard VPN tunnel API (Engine side)."""
|
||||||
@@ -254,52 +253,4 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
|
|||||||
)
|
)
|
||||||
return jsonify({"count": len(sessions), "tunnels": sessions}), 200
|
return jsonify({"count": len(sessions), "tunnels": sessions}), 200
|
||||||
|
|
||||||
@blueprint.route("/api/tunnel/disconnect", methods=["DELETE"])
|
|
||||||
def disconnect_tunnel():
|
|
||||||
requirement = _require_login(app)
|
|
||||||
if requirement:
|
|
||||||
payload, status = requirement
|
|
||||||
return jsonify(payload), status
|
|
||||||
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
agent_id = _normalize_text(body.get("agent_id"))
|
|
||||||
tunnel_id = _normalize_text(body.get("tunnel_id"))
|
|
||||||
reason = _normalize_text(body.get("reason") or "operator_stop")
|
|
||||||
|
|
||||||
tunnel_service = _get_tunnel_service(adapters)
|
|
||||||
_service_log_event(
|
|
||||||
"vpn_api_disconnect_request agent_id={0} tunnel_id={1} reason={2} operator={3} remote={4}".format(
|
|
||||||
agent_id or "-",
|
|
||||||
tunnel_id or "-",
|
|
||||||
reason or "-",
|
|
||||||
(_current_user(app) or {}).get("username") or "-",
|
|
||||||
_request_remote() or "-",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stopped = False
|
|
||||||
if tunnel_id:
|
|
||||||
stopped = tunnel_service.disconnect_by_tunnel(tunnel_id, reason=reason)
|
|
||||||
elif agent_id:
|
|
||||||
stopped = tunnel_service.disconnect(agent_id, reason=reason)
|
|
||||||
else:
|
|
||||||
return jsonify({"error": "agent_id_required"}), 400
|
|
||||||
|
|
||||||
if not stopped:
|
|
||||||
_service_log_event(
|
|
||||||
"vpn_api_disconnect_not_found agent_id={0} tunnel_id={1}".format(
|
|
||||||
agent_id or "-",
|
|
||||||
tunnel_id or "-",
|
|
||||||
),
|
|
||||||
level="WARNING",
|
|
||||||
)
|
|
||||||
return jsonify({"error": "not_found"}), 404
|
|
||||||
|
|
||||||
_service_log_event(
|
|
||||||
"vpn_api_disconnect_response agent_id={0} tunnel_id={1} status=stopped".format(
|
|
||||||
agent_id or "-",
|
|
||||||
tunnel_id or "-",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return jsonify({"status": "stopped", "reason": reason}), 200
|
|
||||||
|
|
||||||
app.register_blueprint(blueprint)
|
app.register_blueprint(blueprint)
|
||||||
|
|||||||
338
Data/Engine/services/API/devices/vnc.py
Normal file
338
Data/Engine/services/API/devices/vnc.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# ======================================================
|
||||||
|
# Data\Engine\services\API\devices\vnc.py
|
||||||
|
# Description: VNC session bootstrap for noVNC WebSocket tunnels.
|
||||||
|
#
|
||||||
|
# API Endpoints (if applicable):
|
||||||
|
# - POST /api/vnc/establish (Token Authenticated) - Establish a VNC session for noVNC.
|
||||||
|
# - POST /api/vnc/disconnect (Token Authenticated) - Disconnect the operator VNC session.
|
||||||
|
# - POST /api/vnc/session (Token Authenticated) - Legacy alias for establish.
|
||||||
|
# ======================================================
|
||||||
|
|
||||||
|
"""VNC session bootstrap endpoints for the Borealis Engine."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, session
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
|
from ...RemoteDesktop.vnc_proxy import VNC_WS_PATH, ensure_vnc_proxy
|
||||||
|
from .tunnel import _get_tunnel_service
|
||||||
|
|
||||||
|
if False: # pragma: no cover - hint for type checkers
|
||||||
|
from .. import EngineServiceAdapters
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user(app) -> Optional[Dict[str, str]]:
|
||||||
|
username = session.get("username")
|
||||||
|
role = session.get("role") or "User"
|
||||||
|
if username:
|
||||||
|
return {"username": username, "role": role}
|
||||||
|
|
||||||
|
token = None
|
||||||
|
auth_header = request.headers.get("Authorization") or ""
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
if not token:
|
||||||
|
token = request.cookies.get("borealis_auth")
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
serializer = URLSafeTimedSerializer(app.secret_key or "borealis-dev-secret", salt="borealis-auth")
|
||||||
|
token_ttl = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30))
|
||||||
|
data = serializer.loads(token, max_age=token_ttl)
|
||||||
|
username = data.get("u")
|
||||||
|
role = data.get("r") or "User"
|
||||||
|
if username:
|
||||||
|
return {"username": username, "role": role}
|
||||||
|
except (BadSignature, SignatureExpired, Exception):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_login(app) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||||
|
user = _current_user(app)
|
||||||
|
if not user:
|
||||||
|
return {"error": "unauthorized"}, 401
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_text(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return str(value).strip()
|
||||||
|
except Exception:
|
||||||
|
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 _is_secure(req) -> bool:
|
||||||
|
if req.is_secure:
|
||||||
|
return True
|
||||||
|
forwarded = (req.headers.get("X-Forwarded-Proto") or "").split(",")[0].strip().lower()
|
||||||
|
return forwarded == "https"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_vnc_password() -> str:
|
||||||
|
# UltraVNC uses the first 8 characters for VNC auth; keep the token to 8 for compatibility.
|
||||||
|
return secrets.token_hex(4)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_vnc_password(adapters: "EngineServiceAdapters", agent_id: str) -> Optional[str]:
|
||||||
|
conn = adapters.db_conn_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT agent_vnc_password FROM devices WHERE agent_id=? ORDER BY last_seen DESC LIMIT 1",
|
||||||
|
(agent_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
return str(row[0]).strip()
|
||||||
|
except Exception:
|
||||||
|
adapters.context.logger.debug("Failed to load agent VNC password", exc_info=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _store_vnc_password(adapters: "EngineServiceAdapters", agent_id: str, password: str) -> None:
|
||||||
|
conn = adapters.db_conn_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE devices SET agent_vnc_password=? WHERE agent_id=?",
|
||||||
|
(password, agent_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
adapters.context.logger.debug("Failed to store agent VNC password", exc_info=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def register_vnc(app, adapters: "EngineServiceAdapters") -> None:
|
||||||
|
blueprint = Blueprint("vnc", __name__)
|
||||||
|
logger = adapters.context.logger.getChild("vnc.api")
|
||||||
|
service_log = adapters.service_log
|
||||||
|
|
||||||
|
def _service_log_event(message: str, *, level: str = "INFO") -> None:
|
||||||
|
if not callable(service_log):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
service_log("VNC", message, level=level)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("vnc service log write failed", exc_info=True)
|
||||||
|
|
||||||
|
def _request_remote() -> str:
|
||||||
|
forwarded = (request.headers.get("X-Forwarded-For") or "").strip()
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return (request.remote_addr or "").strip()
|
||||||
|
|
||||||
|
def _issue_session(agent_id: str, operator_id: Optional[str]) -> Tuple[Dict[str, Any], int]:
|
||||||
|
tunnel_service = _get_tunnel_service(adapters)
|
||||||
|
session_payload = tunnel_service.session_payload(agent_id, include_token=False)
|
||||||
|
if not session_payload:
|
||||||
|
try:
|
||||||
|
session_payload = tunnel_service.connect(
|
||||||
|
agent_id=agent_id,
|
||||||
|
operator_id=operator_id,
|
||||||
|
endpoint_host=_infer_endpoint_host(request),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return {"error": "tunnel_down"}, 409
|
||||||
|
|
||||||
|
vnc_port = int(getattr(adapters.context, "vnc_port", 5900))
|
||||||
|
raw_ports = session_payload.get("allowed_ports") or []
|
||||||
|
allowed_ports = []
|
||||||
|
for value in raw_ports:
|
||||||
|
try:
|
||||||
|
allowed_ports.append(int(value))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if vnc_port not in allowed_ports:
|
||||||
|
return {"error": "vnc_not_allowed", "vnc_port": vnc_port}, 403
|
||||||
|
|
||||||
|
virtual_ip = _normalize_text(session_payload.get("virtual_ip"))
|
||||||
|
host = virtual_ip.split("/")[0] if virtual_ip else ""
|
||||||
|
if not host:
|
||||||
|
return {"error": "virtual_ip_missing"}, 500
|
||||||
|
|
||||||
|
vnc_password = _load_vnc_password(adapters, agent_id)
|
||||||
|
if not vnc_password:
|
||||||
|
vnc_password = _generate_vnc_password()
|
||||||
|
_store_vnc_password(adapters, agent_id, vnc_password)
|
||||||
|
if len(vnc_password) > 8:
|
||||||
|
vnc_password = vnc_password[:8]
|
||||||
|
_store_vnc_password(adapters, agent_id, vnc_password)
|
||||||
|
|
||||||
|
registry = ensure_vnc_proxy(adapters.context, logger=logger)
|
||||||
|
if registry is None:
|
||||||
|
return {"error": "vnc_proxy_unavailable"}, 503
|
||||||
|
|
||||||
|
_service_log_event(
|
||||||
|
"vnc_establish_request agent_id={0} operator={1} remote={2}".format(
|
||||||
|
agent_id,
|
||||||
|
operator_id or "-",
|
||||||
|
_request_remote() or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
vnc_session = registry.create(
|
||||||
|
agent_id=agent_id,
|
||||||
|
host=host,
|
||||||
|
port=vnc_port,
|
||||||
|
operator_id=operator_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
emit_agent = getattr(adapters.context, "emit_agent_event", None)
|
||||||
|
payload = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"port": vnc_port,
|
||||||
|
"allowed_ips": session_payload.get("allowed_ips"),
|
||||||
|
"virtual_ip": host,
|
||||||
|
"password": vnc_password,
|
||||||
|
"reason": "vnc_session_start",
|
||||||
|
}
|
||||||
|
agent_socket_ready = True
|
||||||
|
if callable(emit_agent):
|
||||||
|
agent_socket_ready = bool(emit_agent(agent_id, "vnc_start", payload))
|
||||||
|
if agent_socket_ready:
|
||||||
|
_service_log_event(
|
||||||
|
"vnc_start_emit agent_id={0} port={1} virtual_ip={2}".format(
|
||||||
|
agent_id,
|
||||||
|
vnc_port,
|
||||||
|
host or "-",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_service_log_event(
|
||||||
|
"vnc_start_emit_failed agent_id={0} port={1}".format(
|
||||||
|
agent_id,
|
||||||
|
vnc_port,
|
||||||
|
),
|
||||||
|
level="WARNING",
|
||||||
|
)
|
||||||
|
if not agent_socket_ready:
|
||||||
|
return {"error": "agent_socket_missing"}, 409
|
||||||
|
|
||||||
|
ws_scheme = "wss" if _is_secure(request) else "ws"
|
||||||
|
ws_host = _infer_endpoint_host(request)
|
||||||
|
ws_port = int(getattr(adapters.context, "vnc_ws_port", 4823))
|
||||||
|
ws_url = f"{ws_scheme}://{ws_host}:{ws_port}{VNC_WS_PATH}"
|
||||||
|
|
||||||
|
_service_log_event(
|
||||||
|
"vnc_session_ready agent_id={0} token={1} expires_at={2}".format(
|
||||||
|
agent_id,
|
||||||
|
vnc_session.token[:8],
|
||||||
|
int(vnc_session.expires_at),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"token": vnc_session.token,
|
||||||
|
"ws_url": ws_url,
|
||||||
|
"expires_at": int(vnc_session.expires_at),
|
||||||
|
"virtual_ip": host,
|
||||||
|
"tunnel_id": session_payload.get("tunnel_id"),
|
||||||
|
"engine_virtual_ip": session_payload.get("engine_virtual_ip"),
|
||||||
|
"allowed_ports": session_payload.get("allowed_ports"),
|
||||||
|
"vnc_password": vnc_password,
|
||||||
|
"vnc_port": vnc_port,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
@blueprint.route("/api/vnc/establish", methods=["POST"])
|
||||||
|
def vnc_establish():
|
||||||
|
requirement = _require_login(app)
|
||||||
|
if requirement:
|
||||||
|
payload, status = requirement
|
||||||
|
return jsonify(payload), status
|
||||||
|
|
||||||
|
user = _current_user(app) or {}
|
||||||
|
operator_id = user.get("username") or None
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
agent_id = _normalize_text(body.get("agent_id"))
|
||||||
|
|
||||||
|
if not agent_id:
|
||||||
|
return jsonify({"error": "agent_id_required"}), 400
|
||||||
|
|
||||||
|
payload, status = _issue_session(agent_id, operator_id)
|
||||||
|
return jsonify(payload), status
|
||||||
|
|
||||||
|
@blueprint.route("/api/vnc/session", methods=["POST"])
|
||||||
|
def vnc_session():
|
||||||
|
return vnc_establish()
|
||||||
|
|
||||||
|
@blueprint.route("/api/vnc/disconnect", methods=["POST"])
|
||||||
|
def vnc_disconnect():
|
||||||
|
requirement = _require_login(app)
|
||||||
|
if requirement:
|
||||||
|
payload, status = requirement
|
||||||
|
return jsonify(payload), status
|
||||||
|
|
||||||
|
user = _current_user(app) or {}
|
||||||
|
operator_id = user.get("username") or None
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
agent_id = _normalize_text(body.get("agent_id"))
|
||||||
|
reason = _normalize_text(body.get("reason") or "operator_disconnect")
|
||||||
|
|
||||||
|
if not agent_id:
|
||||||
|
return jsonify({"error": "agent_id_required"}), 400
|
||||||
|
|
||||||
|
registry = ensure_vnc_proxy(adapters.context, logger=logger)
|
||||||
|
revoked = 0
|
||||||
|
if registry is not None:
|
||||||
|
try:
|
||||||
|
revoked = registry.revoke_agent(agent_id)
|
||||||
|
except Exception:
|
||||||
|
revoked = 0
|
||||||
|
|
||||||
|
emit_agent = getattr(adapters.context, "emit_agent_event", None)
|
||||||
|
if callable(emit_agent):
|
||||||
|
try:
|
||||||
|
emit_agent(agent_id, "vnc_stop", {"agent_id": agent_id, "reason": reason})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_service_log_event(
|
||||||
|
"vnc_disconnect agent_id={0} operator={1} reason={2} revoked={3}".format(
|
||||||
|
agent_id,
|
||||||
|
operator_id or "-",
|
||||||
|
reason or "-",
|
||||||
|
revoked,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"status": "disconnected", "revoked": revoked, "reason": reason}), 200
|
||||||
|
|
||||||
|
app.register_blueprint(blueprint)
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
# ======================================================
|
# ======================================================
|
||||||
# Data\Engine\services\RemoteDesktop\__init__.py
|
# Data\Engine\services\RemoteDesktop\__init__.py
|
||||||
# Description: Remote desktop services (Guacamole proxy + session management).
|
# Description: Remote desktop services (VNC proxy + session management).
|
||||||
#
|
#
|
||||||
# API Endpoints (if applicable): None
|
# API Endpoints (if applicable): None
|
||||||
# ======================================================
|
# ======================================================
|
||||||
|
|
||||||
"""Remote desktop service helpers for the Borealis Engine runtime."""
|
"""Remote desktop service helpers for the Borealis Engine runtime."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
# ======================================================
|
|
||||||
# Data\Engine\services\RemoteDesktop\guacamole_proxy.py
|
|
||||||
# Description: Guacamole tunnel proxy (WebSocket -> guacd) for RDP sessions.
|
|
||||||
#
|
|
||||||
# API Endpoints (if applicable): None
|
|
||||||
# ======================================================
|
|
||||||
|
|
||||||
"""Guacamole WebSocket proxy that bridges browser tunnels to guacd."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import ssl
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict, Optional, Tuple
|
|
||||||
from urllib.parse import parse_qs, urlsplit
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
|
|
||||||
GUAC_WS_PATH = "/guacamole"
|
|
||||||
_MAX_MESSAGE_SIZE = 100_000_000
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RdpSession:
|
|
||||||
token: str
|
|
||||||
agent_id: str
|
|
||||||
host: str
|
|
||||||
port: int
|
|
||||||
protocol: str
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
ignore_cert: bool
|
|
||||||
created_at: float
|
|
||||||
expires_at: float
|
|
||||||
operator_id: Optional[str] = None
|
|
||||||
domain: Optional[str] = None
|
|
||||||
security: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RdpSessionRegistry:
|
|
||||||
def __init__(self, ttl_seconds: int, logger: logging.Logger) -> None:
|
|
||||||
self.ttl_seconds = max(30, int(ttl_seconds))
|
|
||||||
self.logger = logger
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._sessions: Dict[str, RdpSession] = {}
|
|
||||||
|
|
||||||
def _cleanup(self, now: Optional[float] = None) -> None:
|
|
||||||
current = now if now is not None else time.time()
|
|
||||||
expired = [token for token, session in self._sessions.items() if session.expires_at <= current]
|
|
||||||
for token in expired:
|
|
||||||
self._sessions.pop(token, None)
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
agent_id: str,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
protocol: str = "rdp",
|
|
||||||
ignore_cert: bool = True,
|
|
||||||
operator_id: Optional[str] = None,
|
|
||||||
domain: Optional[str] = None,
|
|
||||||
security: Optional[str] = None,
|
|
||||||
) -> RdpSession:
|
|
||||||
token = uuid.uuid4().hex
|
|
||||||
now = time.time()
|
|
||||||
expires_at = now + self.ttl_seconds
|
|
||||||
session = RdpSession(
|
|
||||||
token=token,
|
|
||||||
agent_id=agent_id,
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
protocol=protocol,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
ignore_cert=ignore_cert,
|
|
||||||
created_at=now,
|
|
||||||
expires_at=expires_at,
|
|
||||||
operator_id=operator_id,
|
|
||||||
domain=domain,
|
|
||||||
security=security,
|
|
||||||
)
|
|
||||||
with self._lock:
|
|
||||||
self._cleanup(now)
|
|
||||||
self._sessions[token] = session
|
|
||||||
return session
|
|
||||||
|
|
||||||
def consume(self, token: str) -> Optional[RdpSession]:
|
|
||||||
if not token:
|
|
||||||
return None
|
|
||||||
with self._lock:
|
|
||||||
self._cleanup()
|
|
||||||
session = self._sessions.pop(token, None)
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
class GuacamoleProxyServer:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
guacd_host: str,
|
|
||||||
guacd_port: int,
|
|
||||||
registry: RdpSessionRegistry,
|
|
||||||
logger: logging.Logger,
|
|
||||||
ssl_context: Optional[ssl.SSLContext] = None,
|
|
||||||
) -> None:
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.guacd_host = guacd_host
|
|
||||||
self.guacd_port = guacd_port
|
|
||||||
self.registry = registry
|
|
||||||
self.logger = logger
|
|
||||||
self.ssl_context = ssl_context
|
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
self._ready = threading.Event()
|
|
||||||
self._failed = threading.Event()
|
|
||||||
|
|
||||||
def ensure_started(self, timeout: float = 3.0) -> bool:
|
|
||||||
if self._thread and self._thread.is_alive():
|
|
||||||
return not self._failed.is_set()
|
|
||||||
self._failed.clear()
|
|
||||||
self._ready.clear()
|
|
||||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
self._ready.wait(timeout)
|
|
||||||
return not self._failed.is_set()
|
|
||||||
|
|
||||||
def _run(self) -> None:
|
|
||||||
try:
|
|
||||||
asyncio.run(self._serve())
|
|
||||||
except Exception as exc:
|
|
||||||
self._failed.set()
|
|
||||||
self.logger.error("Guacamole proxy server failed: %s", exc)
|
|
||||||
self._ready.set()
|
|
||||||
|
|
||||||
async def _serve(self) -> None:
|
|
||||||
self.logger.info(
|
|
||||||
"Starting Guacamole proxy on %s:%s (guacd %s:%s)",
|
|
||||||
self.host,
|
|
||||||
self.port,
|
|
||||||
self.guacd_host,
|
|
||||||
self.guacd_port,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
server = await websockets.serve(
|
|
||||||
self._handle_client,
|
|
||||||
self.host,
|
|
||||||
self.port,
|
|
||||||
ssl=self.ssl_context,
|
|
||||||
max_size=_MAX_MESSAGE_SIZE,
|
|
||||||
ping_interval=20,
|
|
||||||
ping_timeout=20,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self._failed.set()
|
|
||||||
self._ready.set()
|
|
||||||
raise
|
|
||||||
self._ready.set()
|
|
||||||
await server.wait_closed()
|
|
||||||
|
|
||||||
async def _handle_client(self, websocket, path: str) -> None:
|
|
||||||
parsed = urlsplit(path)
|
|
||||||
if parsed.path != GUAC_WS_PATH:
|
|
||||||
await websocket.close(code=1008, reason="invalid_path")
|
|
||||||
return
|
|
||||||
query = parse_qs(parsed.query or "")
|
|
||||||
token = (query.get("token") or [""])[0]
|
|
||||||
session = self.registry.consume(token)
|
|
||||||
if not session:
|
|
||||||
await websocket.close(code=1008, reason="invalid_session")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger = self.logger.getChild("session")
|
|
||||||
logger.info("Guacamole session start agent_id=%s protocol=%s", session.agent_id, session.protocol)
|
|
||||||
|
|
||||||
try:
|
|
||||||
reader, writer = await asyncio.open_connection(self.guacd_host, self.guacd_port)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("guacd connect failed: %s", exc)
|
|
||||||
await websocket.close(code=1011, reason="guacd_unavailable")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._perform_handshake(reader, writer, session)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("guacd handshake failed: %s", exc)
|
|
||||||
try:
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await websocket.close(code=1011, reason="handshake_failed")
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _ws_to_guacd() -> None:
|
|
||||||
try:
|
|
||||||
async for message in websocket:
|
|
||||||
if message is None:
|
|
||||||
break
|
|
||||||
if isinstance(message, str):
|
|
||||||
data = message.encode("utf-8")
|
|
||||||
else:
|
|
||||||
data = bytes(message)
|
|
||||||
writer.write(data)
|
|
||||||
await writer.drain()
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
writer.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _guacd_to_ws() -> None:
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
data = await reader.read(8192)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
await websocket.send(data.decode("utf-8", errors="ignore"))
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
await websocket.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await asyncio.wait(
|
|
||||||
[asyncio.create_task(_ws_to_guacd()), asyncio.create_task(_guacd_to_ws())],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Guacamole session ended agent_id=%s", session.agent_id)
|
|
||||||
|
|
||||||
async def _perform_handshake(self, reader, writer, session: RdpSession) -> None:
|
|
||||||
writer.write(_encode_instruction("select", session.protocol))
|
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
buffer = b""
|
|
||||||
args = None
|
|
||||||
deadline = time.time() + 8
|
|
||||||
|
|
||||||
while time.time() < deadline:
|
|
||||||
parts, buffer = await _read_instruction(reader, buffer)
|
|
||||||
if not parts:
|
|
||||||
continue
|
|
||||||
op = parts[0]
|
|
||||||
if op == "args":
|
|
||||||
args = parts[1:]
|
|
||||||
break
|
|
||||||
if op == "error":
|
|
||||||
raise RuntimeError("guacd_error:" + " ".join(parts[1:]))
|
|
||||||
if not args:
|
|
||||||
raise RuntimeError("guacd_args_timeout")
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"hostname": session.host,
|
|
||||||
"port": str(session.port),
|
|
||||||
"username": session.username or "",
|
|
||||||
"password": session.password or "",
|
|
||||||
}
|
|
||||||
if session.domain:
|
|
||||||
params["domain"] = session.domain
|
|
||||||
if session.security:
|
|
||||||
params["security"] = session.security
|
|
||||||
if session.ignore_cert:
|
|
||||||
params["ignore-cert"] = "true"
|
|
||||||
|
|
||||||
values = [params.get(name, "") for name in args]
|
|
||||||
writer.write(_encode_instruction("connect", *values))
|
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_instruction(*elements: str) -> bytes:
|
|
||||||
parts = []
|
|
||||||
for element in elements:
|
|
||||||
text = "" if element is None else str(element)
|
|
||||||
parts.append(f"{len(text)}.{text}".encode("utf-8"))
|
|
||||||
return b",".join(parts) + b";"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_instruction(raw: bytes) -> Tuple[str, ...]:
|
|
||||||
parts = []
|
|
||||||
idx = 0
|
|
||||||
length = len(raw)
|
|
||||||
while idx < length:
|
|
||||||
dot = raw.find(b".", idx)
|
|
||||||
if dot < 0:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
element_len = int(raw[idx:dot].decode("ascii") or "0")
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
start = dot + 1
|
|
||||||
end = start + element_len
|
|
||||||
if end > length:
|
|
||||||
break
|
|
||||||
parts.append(raw[start:end].decode("utf-8", errors="ignore"))
|
|
||||||
idx = end
|
|
||||||
if idx < length and raw[idx:idx + 1] == b",":
|
|
||||||
idx += 1
|
|
||||||
return tuple(parts)
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_instruction(reader, buffer: bytes) -> Tuple[Tuple[str, ...], bytes]:
|
|
||||||
while b";" not in buffer:
|
|
||||||
chunk = await reader.read(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
buffer += chunk
|
|
||||||
if b";" not in buffer:
|
|
||||||
return tuple(), buffer
|
|
||||||
instruction, remainder = buffer.split(b";", 1)
|
|
||||||
if not instruction:
|
|
||||||
return tuple(), remainder
|
|
||||||
return _parse_instruction(instruction), remainder
|
|
||||||
|
|
||||||
|
|
||||||
def _build_ssl_context(cert_path: Optional[str], key_path: Optional[str]) -> Optional[ssl.SSLContext]:
|
|
||||||
if not cert_path or not key_path:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
||||||
context.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
|
||||||
return context
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_guacamole_proxy(context: Any, *, logger: Optional[logging.Logger] = None) -> Optional[RdpSessionRegistry]:
|
|
||||||
if logger is None:
|
|
||||||
logger = context.logger if hasattr(context, "logger") else logging.getLogger("borealis.engine.rdp")
|
|
||||||
|
|
||||||
registry = getattr(context, "rdp_registry", None)
|
|
||||||
if registry is None:
|
|
||||||
ttl = int(getattr(context, "rdp_session_ttl_seconds", 120))
|
|
||||||
registry = RdpSessionRegistry(ttl_seconds=ttl, logger=logger)
|
|
||||||
setattr(context, "rdp_registry", registry)
|
|
||||||
|
|
||||||
proxy = getattr(context, "rdp_proxy", None)
|
|
||||||
if proxy is None:
|
|
||||||
cert_path = getattr(context, "tls_bundle_path", None) or getattr(context, "tls_cert_path", None)
|
|
||||||
ssl_context = _build_ssl_context(
|
|
||||||
cert_path,
|
|
||||||
getattr(context, "tls_key_path", None),
|
|
||||||
)
|
|
||||||
proxy = GuacamoleProxyServer(
|
|
||||||
host=str(getattr(context, "rdp_ws_host", "0.0.0.0")),
|
|
||||||
port=int(getattr(context, "rdp_ws_port", 4823)),
|
|
||||||
guacd_host=str(getattr(context, "guacd_host", "127.0.0.1")),
|
|
||||||
guacd_port=int(getattr(context, "guacd_port", 4822)),
|
|
||||||
registry=registry,
|
|
||||||
logger=logger.getChild("guacamole_proxy"),
|
|
||||||
ssl_context=ssl_context,
|
|
||||||
)
|
|
||||||
setattr(context, "rdp_proxy", proxy)
|
|
||||||
|
|
||||||
if not proxy.ensure_started():
|
|
||||||
logger.error("Guacamole proxy failed to start; RDP sessions unavailable.")
|
|
||||||
return None
|
|
||||||
return registry
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["GUAC_WS_PATH", "RdpSessionRegistry", "GuacamoleProxyServer", "ensure_guacamole_proxy"]
|
|
||||||
285
Data/Engine/services/RemoteDesktop/vnc_proxy.py
Normal file
285
Data/Engine/services/RemoteDesktop/vnc_proxy.py
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# ======================================================
|
||||||
|
# Data\Engine\services\RemoteDesktop\vnc_proxy.py
|
||||||
|
# Description: VNC tunnel proxy (WebSocket -> TCP) for noVNC sessions.
|
||||||
|
#
|
||||||
|
# API Endpoints (if applicable): None
|
||||||
|
# ======================================================
|
||||||
|
|
||||||
|
"""VNC WebSocket proxy that bridges browser sessions to agent VNC servers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Dict, Optional, Tuple
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
VNC_WS_PATH = "/vnc"
|
||||||
|
_MAX_MESSAGE_SIZE = 100_000_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VncSession:
|
||||||
|
token: str
|
||||||
|
agent_id: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
created_at: float
|
||||||
|
expires_at: float
|
||||||
|
operator_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VncSessionRegistry:
|
||||||
|
def __init__(self, ttl_seconds: int, logger: logging.Logger) -> None:
|
||||||
|
self.ttl_seconds = max(30, int(ttl_seconds))
|
||||||
|
self.logger = logger
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._sessions: Dict[str, VncSession] = {}
|
||||||
|
|
||||||
|
def _cleanup(self, now: Optional[float] = None) -> None:
|
||||||
|
current = now if now is not None else time.time()
|
||||||
|
expired = [token for token, session in self._sessions.items() if session.expires_at <= current]
|
||||||
|
for token in expired:
|
||||||
|
self._sessions.pop(token, None)
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
operator_id: Optional[str] = None,
|
||||||
|
) -> VncSession:
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
now = time.time()
|
||||||
|
expires_at = now + self.ttl_seconds
|
||||||
|
session = VncSession(
|
||||||
|
token=token,
|
||||||
|
agent_id=agent_id,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
created_at=now,
|
||||||
|
expires_at=expires_at,
|
||||||
|
operator_id=operator_id,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._cleanup(now)
|
||||||
|
self._sessions[token] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
def consume(self, token: str) -> Optional[VncSession]:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
self._cleanup()
|
||||||
|
session = self._sessions.pop(token, None)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def revoke_agent(self, agent_id: str) -> int:
|
||||||
|
if not agent_id:
|
||||||
|
return 0
|
||||||
|
removed = 0
|
||||||
|
with self._lock:
|
||||||
|
self._cleanup()
|
||||||
|
tokens = [token for token, session in self._sessions.items() if session.agent_id == agent_id]
|
||||||
|
for token in tokens:
|
||||||
|
if self._sessions.pop(token, None):
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
class VncProxyServer:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
registry: VncSessionRegistry,
|
||||||
|
logger: logging.Logger,
|
||||||
|
emit_agent_event: Optional[Callable[[str, str, Any], bool]] = None,
|
||||||
|
ssl_context: Optional[ssl.SSLContext] = None,
|
||||||
|
) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.registry = registry
|
||||||
|
self.logger = logger
|
||||||
|
self._emit_agent_event = emit_agent_event
|
||||||
|
self.ssl_context = ssl_context
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._ready = threading.Event()
|
||||||
|
self._failed = threading.Event()
|
||||||
|
|
||||||
|
def ensure_started(self, timeout: float = 3.0) -> bool:
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return not self._failed.is_set()
|
||||||
|
self._failed.clear()
|
||||||
|
self._ready.clear()
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
self._ready.wait(timeout)
|
||||||
|
return not self._failed.is_set()
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
try:
|
||||||
|
asyncio.run(self._serve())
|
||||||
|
except Exception as exc:
|
||||||
|
self._failed.set()
|
||||||
|
self.logger.error("VNC proxy server failed: %s", exc)
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
|
async def _serve(self) -> None:
|
||||||
|
self.logger.info("Starting VNC proxy on %s:%s", self.host, self.port)
|
||||||
|
try:
|
||||||
|
server = await websockets.serve(
|
||||||
|
self._handle_client,
|
||||||
|
self.host,
|
||||||
|
self.port,
|
||||||
|
ssl=self.ssl_context,
|
||||||
|
max_size=_MAX_MESSAGE_SIZE,
|
||||||
|
ping_interval=20,
|
||||||
|
ping_timeout=20,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._failed.set()
|
||||||
|
self._ready.set()
|
||||||
|
raise
|
||||||
|
self._ready.set()
|
||||||
|
await server.wait_closed()
|
||||||
|
|
||||||
|
async def _handle_client(self, websocket, path: str) -> None:
|
||||||
|
parsed = urlsplit(path)
|
||||||
|
if parsed.path != VNC_WS_PATH:
|
||||||
|
await websocket.close(code=1008, reason="invalid_path")
|
||||||
|
return
|
||||||
|
query = parse_qs(parsed.query or "")
|
||||||
|
token = (query.get("token") or [""])[0]
|
||||||
|
session = self.registry.consume(token)
|
||||||
|
if not session:
|
||||||
|
await websocket.close(code=1008, reason="invalid_session")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger = self.logger.getChild("session")
|
||||||
|
logger.info("VNC session start agent_id=%s", session.agent_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
reader, writer = await self._connect_vnc(session.host, session.port)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("VNC connect failed: %s", exc)
|
||||||
|
await websocket.close(code=1011, reason="vnc_unavailable")
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _ws_to_tcp() -> None:
|
||||||
|
try:
|
||||||
|
async for message in websocket:
|
||||||
|
if message is None:
|
||||||
|
break
|
||||||
|
if isinstance(message, str):
|
||||||
|
data = message.encode("utf-8")
|
||||||
|
else:
|
||||||
|
data = bytes(message)
|
||||||
|
writer.write(data)
|
||||||
|
await writer.drain()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _tcp_to_ws() -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await reader.read(8192)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
await websocket.send(data)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await websocket.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.wait(
|
||||||
|
[asyncio.create_task(_ws_to_tcp()), asyncio.create_task(_tcp_to_ws())],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.info("VNC session ended agent_id=%s", session.agent_id)
|
||||||
|
self._notify_agent_session_end(session, reason="vnc_session_end")
|
||||||
|
|
||||||
|
async def _connect_vnc(self, host: str, port: int) -> Tuple[Any, Any]:
|
||||||
|
attempts = 5
|
||||||
|
delay = 0.5
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
for attempt in range(attempts):
|
||||||
|
try:
|
||||||
|
return await asyncio.open_connection(host, port)
|
||||||
|
except Exception as exc:
|
||||||
|
last_exc = exc
|
||||||
|
if attempt < attempts - 1:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
if last_exc:
|
||||||
|
raise last_exc
|
||||||
|
raise RuntimeError("vnc_connect_failed")
|
||||||
|
|
||||||
|
def _notify_agent_session_end(self, session: VncSession, reason: str) -> None:
|
||||||
|
if not self._emit_agent_event:
|
||||||
|
return
|
||||||
|
payload = {"agent_id": session.agent_id, "reason": reason}
|
||||||
|
try:
|
||||||
|
self._emit_agent_event(session.agent_id, "vnc_stop", payload)
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Failed to emit vnc_stop for agent_id=%s", session.agent_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ssl_context(cert_path: Optional[str], key_path: Optional[str]) -> Optional[ssl.SSLContext]:
|
||||||
|
if not cert_path or not key_path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
context.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||||||
|
return context
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_vnc_proxy(context: Any, *, logger: Optional[logging.Logger] = None) -> Optional[VncSessionRegistry]:
|
||||||
|
if logger is None:
|
||||||
|
logger = context.logger if hasattr(context, "logger") else logging.getLogger("borealis.engine.vnc")
|
||||||
|
|
||||||
|
registry = getattr(context, "vnc_registry", None)
|
||||||
|
if registry is None:
|
||||||
|
ttl = int(getattr(context, "vnc_session_ttl_seconds", 120))
|
||||||
|
registry = VncSessionRegistry(ttl_seconds=ttl, logger=logger)
|
||||||
|
setattr(context, "vnc_registry", registry)
|
||||||
|
|
||||||
|
proxy = getattr(context, "vnc_proxy", None)
|
||||||
|
if proxy is None:
|
||||||
|
cert_path = getattr(context, "tls_bundle_path", None) or getattr(context, "tls_cert_path", None)
|
||||||
|
ssl_context = _build_ssl_context(
|
||||||
|
cert_path,
|
||||||
|
getattr(context, "tls_key_path", None),
|
||||||
|
)
|
||||||
|
proxy = VncProxyServer(
|
||||||
|
host=str(getattr(context, "vnc_ws_host", "0.0.0.0")),
|
||||||
|
port=int(getattr(context, "vnc_ws_port", 4823)),
|
||||||
|
registry=registry,
|
||||||
|
logger=logger.getChild("vnc_proxy"),
|
||||||
|
emit_agent_event=getattr(context, "emit_agent_event", None),
|
||||||
|
ssl_context=ssl_context,
|
||||||
|
)
|
||||||
|
setattr(context, "vnc_proxy", proxy)
|
||||||
|
|
||||||
|
if not proxy.ensure_started():
|
||||||
|
logger.error("VNC proxy failed to start; VNC sessions unavailable.")
|
||||||
|
return None
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["VNC_WS_PATH", "VncSessionRegistry", "VncProxyServer", "ensure_vnc_proxy"]
|
||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -22,6 +23,13 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|||||||
from .wireguard_server import WireGuardServerManager
|
from .wireguard_server import WireGuardServerManager
|
||||||
|
|
||||||
|
|
||||||
|
def _env_flag(name: str, *, default: bool) -> bool:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return str(value).strip().lower() not in ("0", "false", "no", "off")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VpnSession:
|
class VpnSession:
|
||||||
tunnel_id: str
|
tunnel_id: str
|
||||||
@@ -62,14 +70,17 @@ class VpnTunnelService:
|
|||||||
self.logger = context.logger.getChild("vpn_tunnel")
|
self.logger = context.logger.getChild("vpn_tunnel")
|
||||||
self.activity_logger = self.wg.logger.getChild("device_activity")
|
self.activity_logger = self.wg.logger.getChild("device_activity")
|
||||||
self.idle_seconds = max(60, int(idle_seconds))
|
self.idle_seconds = max(60, int(idle_seconds))
|
||||||
|
self.persistent = _env_flag("BOREALIS_WIREGUARD_PERSISTENT", default=True)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._sessions_by_agent: Dict[str, VpnSession] = {}
|
self._sessions_by_agent: Dict[str, VpnSession] = {}
|
||||||
self._sessions_by_tunnel: Dict[str, VpnSession] = {}
|
self._sessions_by_tunnel: Dict[str, VpnSession] = {}
|
||||||
self._engine_ip = ipaddress.ip_interface(context.wireguard_engine_virtual_ip)
|
self._engine_ip = ipaddress.ip_interface(context.wireguard_engine_virtual_ip)
|
||||||
self._peer_network = ipaddress.ip_network(context.wireguard_peer_network, strict=False)
|
self._peer_network = ipaddress.ip_network(context.wireguard_peer_network, strict=False)
|
||||||
self._cleanup_listener()
|
self._cleanup_listener()
|
||||||
self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True)
|
self._idle_thread: Optional[threading.Thread] = None
|
||||||
self._idle_thread.start()
|
if not self.persistent:
|
||||||
|
self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True)
|
||||||
|
self._idle_thread.start()
|
||||||
|
|
||||||
def _idle_loop(self) -> None:
|
def _idle_loop(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
@@ -90,7 +101,7 @@ class VpnTunnelService:
|
|||||||
self.idle_seconds,
|
self.idle_seconds,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.disconnect(session.agent_id, reason="idle_timeout")
|
self.disconnect(session.agent_id, reason="idle_timeout", force=True)
|
||||||
|
|
||||||
def _allocate_virtual_ip(self, agent_id: str) -> str:
|
def _allocate_virtual_ip(self, agent_id: str) -> str:
|
||||||
existing = self._sessions_by_agent.get(agent_id)
|
existing = self._sessions_by_agent.get(agent_id)
|
||||||
@@ -226,12 +237,12 @@ class VpnTunnelService:
|
|||||||
self.logger.debug("Failed to write vpn_tunnel service log entry", exc_info=True)
|
self.logger.debug("Failed to write vpn_tunnel service log entry", exc_info=True)
|
||||||
|
|
||||||
def _cleanup_listener(self) -> None:
|
def _cleanup_listener(self) -> None:
|
||||||
try:
|
self._service_log_event("vpn_listener_cleanup_skipped reason=startup")
|
||||||
self.wg.stop_listener(ignore_missing=True)
|
|
||||||
self._service_log_event("vpn_listener_cleanup reason=startup")
|
def _is_soft_disconnect(self, reason: Optional[str]) -> bool:
|
||||||
except Exception:
|
if not reason:
|
||||||
self.logger.debug("Failed to clean up WireGuard listener on startup.", exc_info=True)
|
return False
|
||||||
self._service_log_event("vpn_listener_cleanup_failed reason=startup", level="WARNING")
|
return str(reason).lower() in ("operator_disconnect", "component_unmount")
|
||||||
|
|
||||||
def _refresh_listener(self) -> None:
|
def _refresh_listener(self) -> None:
|
||||||
peers: List[Mapping[str, object]] = []
|
peers: List[Mapping[str, object]] = []
|
||||||
@@ -432,15 +443,60 @@ class VpnTunnelService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("vpn_tunnel_activity emit failed for agent_id=%s", agent_id, exc_info=True)
|
self.logger.debug("vpn_tunnel_activity emit failed for agent_id=%s", agent_id, exc_info=True)
|
||||||
|
|
||||||
def disconnect(self, agent_id: str, reason: str = "operator_stop") -> bool:
|
def disconnect(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
reason: str = "operator_stop",
|
||||||
|
*,
|
||||||
|
operator_id: Optional[str] = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> bool:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
session = self._sessions_by_agent.pop(agent_id, None)
|
session = self._sessions_by_agent.get(agent_id)
|
||||||
if not session:
|
if not session:
|
||||||
self._service_log_event(
|
self._service_log_event(
|
||||||
"vpn_tunnel_disconnect_missing agent_id={0} reason={1}".format(agent_id or "-", reason or "-")
|
"vpn_tunnel_disconnect_missing agent_id={0} reason={1}".format(agent_id or "-", reason or "-")
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
self._sessions_by_tunnel.pop(session.tunnel_id, None)
|
if self.persistent and not force:
|
||||||
|
if operator_id:
|
||||||
|
try:
|
||||||
|
session.operator_ids.discard(operator_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
session.last_activity = time.time()
|
||||||
|
operator_text = ",".join(sorted(filter(None, session.operator_ids))) or "-"
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_keepalive agent_id={0} tunnel_id={1} reason={2} operators={3}".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
reason or "-",
|
||||||
|
operator_text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
if not force and self._is_soft_disconnect(reason):
|
||||||
|
if operator_id:
|
||||||
|
try:
|
||||||
|
session.operator_ids.discard(operator_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
session.last_activity = time.time()
|
||||||
|
operator_text = ",".join(sorted(filter(None, session.operator_ids))) or "-"
|
||||||
|
self._service_log_event(
|
||||||
|
"vpn_tunnel_keepalive agent_id={0} tunnel_id={1} reason={2} operators={3}".format(
|
||||||
|
session.agent_id,
|
||||||
|
session.tunnel_id,
|
||||||
|
reason or "-",
|
||||||
|
operator_text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
session = self._sessions_by_agent.pop(agent_id, None)
|
||||||
|
if session:
|
||||||
|
self._sessions_by_tunnel.pop(session.tunnel_id, None)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.wg.remove_firewall_rules(session.firewall_rules)
|
self.wg.remove_firewall_rules(session.firewall_rules)
|
||||||
@@ -461,7 +517,14 @@ class VpnTunnelService:
|
|||||||
self._log_device_activity(session, event="stop", reason=reason)
|
self._log_device_activity(session, event="stop", reason=reason)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def disconnect_by_tunnel(self, tunnel_id: str, reason: str = "operator_stop") -> bool:
|
def disconnect_by_tunnel(
|
||||||
|
self,
|
||||||
|
tunnel_id: str,
|
||||||
|
reason: str = "operator_stop",
|
||||||
|
*,
|
||||||
|
operator_id: Optional[str] = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> bool:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
session = self._sessions_by_tunnel.get(tunnel_id)
|
session = self._sessions_by_tunnel.get(tunnel_id)
|
||||||
if not session:
|
if not session:
|
||||||
@@ -469,7 +532,7 @@ class VpnTunnelService:
|
|||||||
"vpn_tunnel_disconnect_missing tunnel_id={0} reason={1}".format(tunnel_id or "-", reason or "-")
|
"vpn_tunnel_disconnect_missing tunnel_id={0} reason={1}".format(tunnel_id or "-", reason or "-")
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return self.disconnect(session.agent_id, reason=reason)
|
return self.disconnect(session.agent_id, reason=reason, operator_id=operator_id, force=force)
|
||||||
|
|
||||||
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:
|
||||||
@@ -704,7 +767,7 @@ class VpnTunnelService:
|
|||||||
"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,
|
||||||
"idle_seconds": self.idle_seconds,
|
"idle_seconds": 0 if self.persistent else self.idle_seconds,
|
||||||
"allowed_ports": list(session.allowed_ports),
|
"allowed_ports": list(session.allowed_ports),
|
||||||
"connected_operators": len([o for o in session.operator_ids if o]),
|
"connected_operators": len([o for o in session.operator_ids if o]),
|
||||||
}
|
}
|
||||||
@@ -729,6 +792,6 @@ class VpnTunnelService:
|
|||||||
"last_activity_iso": self._ts_to_iso(session.last_activity),
|
"last_activity_iso": self._ts_to_iso(session.last_activity),
|
||||||
"expires_at": int(session.expires_at),
|
"expires_at": int(session.expires_at),
|
||||||
"expires_at_iso": self._ts_to_iso(session.expires_at),
|
"expires_at_iso": self._ts_to_iso(session.expires_at),
|
||||||
"idle_seconds": self.idle_seconds,
|
"idle_seconds": 0 if self.persistent else self.idle_seconds,
|
||||||
"status": "up",
|
"status": "up",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,25 @@ class WireGuardServerManager:
|
|||||||
return match.group(1).upper()
|
return match.group(1).upper()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _service_exists(self) -> bool:
|
||||||
|
code, _, _ = self._run_command(["sc.exe", "query", self._service_id()])
|
||||||
|
return code == 0
|
||||||
|
|
||||||
|
def _stop_service(self, *, timeout: int = 20) -> bool:
|
||||||
|
service_id = self._service_id()
|
||||||
|
state = self._query_service_state()
|
||||||
|
if not state:
|
||||||
|
return False
|
||||||
|
if state == "STOPPED":
|
||||||
|
return True
|
||||||
|
self._run_command(["sc.exe", "stop", service_id])
|
||||||
|
for _ in range(max(1, timeout)):
|
||||||
|
time.sleep(1)
|
||||||
|
state = self._query_service_state()
|
||||||
|
if state == "STOPPED":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _ensure_service_display_name(self) -> None:
|
def _ensure_service_display_name(self) -> None:
|
||||||
if not self._service_display_name:
|
if not self._service_display_name:
|
||||||
return
|
return
|
||||||
@@ -172,9 +191,9 @@ class WireGuardServerManager:
|
|||||||
if code != 0 and err:
|
if code != 0 and err:
|
||||||
self.logger.warning("Failed to set WireGuard service display name: %s", err)
|
self.logger.warning("Failed to set WireGuard service display name: %s", err)
|
||||||
|
|
||||||
def _ensure_service_running(self) -> None:
|
def _ensure_service_running(self, *, timeout: int = 20) -> None:
|
||||||
service_id = self._service_id()
|
service_id = self._service_id()
|
||||||
for _ in range(6):
|
for _ in range(max(1, timeout)):
|
||||||
state = self._query_service_state()
|
state = self._query_service_state()
|
||||||
if state == "RUNNING":
|
if state == "RUNNING":
|
||||||
return
|
return
|
||||||
@@ -183,8 +202,20 @@ class WireGuardServerManager:
|
|||||||
if code != 0:
|
if code != 0:
|
||||||
self.logger.error("Failed to start WireGuard tunnel service %s err=%s", service_id, err)
|
self.logger.error("Failed to start WireGuard tunnel service %s err=%s", service_id, err)
|
||||||
break
|
break
|
||||||
|
if state in ("START_PENDING", "STOP_PENDING"):
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
state = self._query_service_state()
|
state = self._query_service_state()
|
||||||
|
if state == "START_PENDING":
|
||||||
|
self.logger.warning("WireGuard tunnel service still START_PENDING; attempting restart.")
|
||||||
|
self._stop_service(timeout=10)
|
||||||
|
self._run_command(["sc.exe", "start", service_id])
|
||||||
|
for _ in range(10):
|
||||||
|
time.sleep(1)
|
||||||
|
if self._query_service_state() == "RUNNING":
|
||||||
|
return
|
||||||
|
state = self._query_service_state()
|
||||||
raise RuntimeError(f"WireGuard tunnel service {service_id} failed to start (state={state})")
|
raise RuntimeError(f"WireGuard tunnel service {service_id} failed to start (state={state})")
|
||||||
|
|
||||||
def _normalise_allowed_ports(
|
def _normalise_allowed_ports(
|
||||||
@@ -329,6 +360,7 @@ class WireGuardServerManager:
|
|||||||
for idx, rule in enumerate(rules):
|
for idx, rule in enumerate(rules):
|
||||||
name = f"Borealis-WG-Agent-{peer.get('agent_id','')}-{idx}"
|
name = f"Borealis-WG-Agent-{peer.get('agent_id','')}-{idx}"
|
||||||
protocol = str(rule.get("protocol") or "TCP").upper()
|
protocol = str(rule.get("protocol") or "TCP").upper()
|
||||||
|
self._run_command(["netsh", "advfirewall", "firewall", "delete", "rule", f"name={name}"])
|
||||||
args = [
|
args = [
|
||||||
"netsh",
|
"netsh",
|
||||||
"advfirewall",
|
"advfirewall",
|
||||||
@@ -374,8 +406,12 @@ class WireGuardServerManager:
|
|||||||
config_path.write_text(rendered, encoding="utf-8")
|
config_path.write_text(rendered, encoding="utf-8")
|
||||||
self.logger.info("Rendered WireGuard config to %s", config_path)
|
self.logger.info("Rendered WireGuard config to %s", config_path)
|
||||||
|
|
||||||
# Ensure old service is removed before re-installing.
|
if self._service_exists():
|
||||||
self.stop_listener()
|
if not self._stop_service(timeout=20):
|
||||||
|
self.logger.warning("WireGuard tunnel service did not stop cleanly before restart.")
|
||||||
|
self._ensure_service_display_name()
|
||||||
|
self._ensure_service_running(timeout=25)
|
||||||
|
return
|
||||||
|
|
||||||
args = [self._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)
|
||||||
@@ -384,21 +420,22 @@ class WireGuardServerManager:
|
|||||||
raise RuntimeError(f"WireGuard installtunnelservice failed: {err}")
|
raise RuntimeError(f"WireGuard installtunnelservice failed: {err}")
|
||||||
self.logger.info("WireGuard listener installed (service=%s)", config_path.stem)
|
self.logger.info("WireGuard listener installed (service=%s)", config_path.stem)
|
||||||
self._ensure_service_display_name()
|
self._ensure_service_display_name()
|
||||||
self._ensure_service_running()
|
self._ensure_service_running(timeout=25)
|
||||||
|
|
||||||
def stop_listener(self, *, ignore_missing: bool = False) -> None:
|
def stop_listener(self, *, ignore_missing: bool = False) -> None:
|
||||||
"""Stop and remove the WireGuard tunnel service."""
|
"""Stop the WireGuard tunnel service (leave installed for reuse)."""
|
||||||
|
|
||||||
args = [self._wireguard_exe, "/uninstalltunnelservice", self._service_name]
|
if not self._service_exists():
|
||||||
code, out, err = self._run_command(args)
|
if ignore_missing:
|
||||||
if code != 0:
|
|
||||||
err_text = " ".join([out or "", err or ""]).strip().lower()
|
|
||||||
if ignore_missing and ("does not exist" in err_text or "not exist" in err_text):
|
|
||||||
self.logger.info("WireGuard tunnel service already absent")
|
self.logger.info("WireGuard tunnel service already absent")
|
||||||
return
|
return
|
||||||
self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err)
|
self.logger.warning("WireGuard tunnel service not found during stop.")
|
||||||
else:
|
return
|
||||||
self.logger.info("WireGuard tunnel service removed")
|
|
||||||
|
if not self._stop_service(timeout=20):
|
||||||
|
self.logger.warning("WireGuard tunnel service did not stop cleanly.")
|
||||||
|
return
|
||||||
|
self.logger.info("WireGuard tunnel service stopped")
|
||||||
|
|
||||||
def build_firewall_rules(
|
def build_firewall_rules(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -187,7 +187,10 @@ class VpnShellBridge:
|
|||||||
existing.close()
|
existing.close()
|
||||||
status = service.status(agent_id)
|
status = service.status(agent_id)
|
||||||
if not status:
|
if not status:
|
||||||
return None
|
try:
|
||||||
|
status = service.connect(agent_id=agent_id, operator_id=None, endpoint_host=None)
|
||||||
|
except Exception:
|
||||||
|
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)
|
||||||
tcp = None
|
tcp = None
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
"@mui/x-tree-view": "8.10.0",
|
"@mui/x-tree-view": "8.10.0",
|
||||||
"ag-grid-community": "34.2.0",
|
"ag-grid-community": "34.2.0",
|
||||||
"ag-grid-react": "34.2.0",
|
"ag-grid-react": "34.2.0",
|
||||||
|
"@novnc/novnc": "^1.4.0",
|
||||||
"dayjs": "1.11.18",
|
"dayjs": "1.11.18",
|
||||||
"guacamole-common-js": "1.5.0",
|
|
||||||
"normalize.css": "8.0.1",
|
"normalize.css": "8.0.1",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
"react-simple-code-editor": "0.13.1",
|
"react-simple-code-editor": "0.13.1",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import Editor from "react-simple-code-editor";
|
|||||||
import { AgGridReact } from "ag-grid-react";
|
import { AgGridReact } from "ag-grid-react";
|
||||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||||
import ReverseTunnelPowershell from "./ReverseTunnel/Powershell.jsx";
|
import ReverseTunnelPowershell from "./ReverseTunnel/Powershell.jsx";
|
||||||
import ReverseTunnelRdp from "./ReverseTunnel/RDP.jsx";
|
import ReverseTunnelVnc from "./ReverseTunnel/VNC.jsx";
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
@@ -85,12 +85,6 @@ const buildVpnGroups = (shellPort) => {
|
|||||||
description: "Web terminal access over the VPN tunnel.",
|
description: "Web terminal access over the VPN tunnel.",
|
||||||
ports: [normalizedShell],
|
ports: [normalizedShell],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "rdp",
|
|
||||||
label: "RDP",
|
|
||||||
description: "Remote Desktop (TCP 3389).",
|
|
||||||
ports: [3389],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "winrm",
|
key: "winrm",
|
||||||
label: "WinRM",
|
label: "WinRM",
|
||||||
@@ -121,7 +115,7 @@ const TOP_TABS = [
|
|||||||
{ key: "activity", label: "Activity History", icon: ListAltRoundedIcon },
|
{ key: "activity", label: "Activity History", icon: ListAltRoundedIcon },
|
||||||
{ key: "advanced", label: "Advanced Config", icon: TuneRoundedIcon },
|
{ key: "advanced", label: "Advanced Config", icon: TuneRoundedIcon },
|
||||||
{ key: "shell", label: "Remote Shell", icon: TerminalRoundedIcon },
|
{ key: "shell", label: "Remote Shell", icon: TerminalRoundedIcon },
|
||||||
{ key: "rdp", label: "Remote Desktop", icon: DesktopWindowsRoundedIcon },
|
{ key: "vnc", label: "Remote Desktop (VNC)", icon: DesktopWindowsRoundedIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
const myTheme = themeQuartz.withParams({
|
const myTheme = themeQuartz.withParams({
|
||||||
@@ -1542,7 +1536,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPage
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ReverseTunnelRdp device={tunnelDevice} />
|
<ReverseTunnelVnc device={tunnelDevice} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
const localSocketRef = useRef(false);
|
const localSocketRef = useRef(false);
|
||||||
const terminalRef = useRef(null);
|
const terminalRef = useRef(null);
|
||||||
const agentIdRef = useRef("");
|
const agentIdRef = useRef("");
|
||||||
const tunnelIdRef = useRef("");
|
|
||||||
|
|
||||||
const agentId = useMemo(() => {
|
const agentId = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -115,9 +114,6 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
agentIdRef.current = agentId;
|
agentIdRef.current = agentId;
|
||||||
}, [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;
|
||||||
@@ -181,15 +177,14 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [output, scrollToBottom]);
|
}, [output, scrollToBottom]);
|
||||||
|
|
||||||
const stopTunnel = useCallback(async (reason = "operator_disconnect") => {
|
const disconnectShell = useCallback(async (reason = "operator_disconnect") => {
|
||||||
const currentAgentId = agentIdRef.current;
|
const currentAgentId = agentIdRef.current;
|
||||||
if (!currentAgentId) return;
|
if (!currentAgentId) return;
|
||||||
const currentTunnelId = tunnelIdRef.current;
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/tunnel/disconnect", {
|
await fetch("/api/shell/disconnect", {
|
||||||
method: "DELETE",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }),
|
body: JSON.stringify({ agent_id: currentAgentId, reason }),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
@@ -206,14 +201,14 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
setStatusMessage("");
|
setStatusMessage("");
|
||||||
try {
|
try {
|
||||||
await closeShell();
|
await closeShell();
|
||||||
await stopTunnel("operator_disconnect");
|
await disconnectShell("operator_disconnect");
|
||||||
} finally {
|
} finally {
|
||||||
setTunnel(null);
|
setTunnel(null);
|
||||||
setShellState("closed");
|
setShellState("closed");
|
||||||
setSessionState("idle");
|
setSessionState("idle");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [closeShell, stopTunnel]);
|
}, [closeShell, disconnectShell]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = ensureSocket();
|
const socket = ensureSocket();
|
||||||
@@ -250,77 +245,39 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
closeShell();
|
closeShell();
|
||||||
stopTunnel("component_unmount");
|
disconnectShell("component_unmount");
|
||||||
};
|
};
|
||||||
}, [closeShell, stopTunnel]);
|
}, [closeShell, disconnectShell]);
|
||||||
|
|
||||||
const requestTunnel = useCallback(async () => {
|
const requestTunnel = useCallback(async () => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
setStatusMessage("Agent ID is required to connect.");
|
setStatusMessage("Agent ID is required to establish.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatusMessage("");
|
setStatusMessage("");
|
||||||
try {
|
try {
|
||||||
try {
|
|
||||||
const readinessResp = await fetch(
|
|
||||||
`/api/tunnel/status?agent_id=${encodeURIComponent(agentId)}`
|
|
||||||
);
|
|
||||||
const readinessData = await readinessResp.json().catch(() => ({}));
|
|
||||||
if (readinessResp.ok && readinessData?.agent_socket !== true) {
|
|
||||||
await handleAgentOnboarding();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// best-effort readiness check
|
|
||||||
}
|
|
||||||
|
|
||||||
setSessionState("connecting");
|
setSessionState("connecting");
|
||||||
setShellState("opening");
|
setShellState("opening");
|
||||||
const resp = await fetch("/api/tunnel/connect", {
|
const resp = await fetch("/api/shell/establish", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
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) {
|
if (!resp.ok) {
|
||||||
|
if (data?.error === "agent_socket_missing") {
|
||||||
|
await handleAgentOnboarding();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const detail = data?.detail ? `: ${data.detail}` : "";
|
const detail = data?.detail ? `: ${data.detail}` : "";
|
||||||
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
|
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
|
||||||
}
|
}
|
||||||
tunnelIdRef.current = data?.tunnel_id || "";
|
if (data?.agent_socket === false) {
|
||||||
const waitForTunnelReady = async () => {
|
await handleAgentOnboarding();
|
||||||
const deadline = Date.now() + 60000;
|
|
||||||
let lastError = "";
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const statusResp = await fetch(
|
|
||||||
`/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1`
|
|
||||||
);
|
|
||||||
const statusData = await statusResp.json().catch(() => ({}));
|
|
||||||
if (statusData?.error === "agent_socket_missing" || (statusResp.ok && statusData?.agent_socket === false)) {
|
|
||||||
await handleAgentOnboarding();
|
|
||||||
await stopTunnel("agent_onboarding_pending");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (statusResp.ok && statusData?.status === "up") {
|
|
||||||
const agentSocket = statusData?.agent_socket;
|
|
||||||
const agentReady = agentSocket === undefined ? true : Boolean(agentSocket);
|
|
||||||
if (agentReady) {
|
|
||||||
return statusData;
|
|
||||||
}
|
|
||||||
setStatusMessage("Waiting for agent VPN socket to register...");
|
|
||||||
} else if (statusData?.error) {
|
|
||||||
lastError = statusData.error;
|
|
||||||
}
|
|
||||||
await sleep(2000);
|
|
||||||
}
|
|
||||||
throw new Error(lastError || "Tunnel not ready");
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusData = await waitForTunnelReady();
|
|
||||||
if (!statusData) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTunnel({ ...data, ...statusData });
|
setTunnel(data);
|
||||||
|
|
||||||
const socket = ensureSocket();
|
const socket = ensureSocket();
|
||||||
const openShellWithRetry = async () => {
|
const openShellWithRetry = async () => {
|
||||||
@@ -335,7 +292,6 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
}
|
}
|
||||||
if (openResp.error === "agent_socket_missing") {
|
if (openResp.error === "agent_socket_missing") {
|
||||||
await handleAgentOnboarding();
|
await handleAgentOnboarding();
|
||||||
await stopTunnel("agent_onboarding_pending");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
lastError = openResp.error;
|
lastError = openResp.error;
|
||||||
@@ -359,7 +315,7 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [agentId, ensureSocket, handleAgentOnboarding, stopTunnel]);
|
}, [agentId, ensureSocket, handleAgentOnboarding]);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
async (text) => {
|
async (text) => {
|
||||||
@@ -414,7 +370,7 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
disabled={loading || (!isConnected && !agentId)}
|
disabled={loading || (!isConnected && !agentId)}
|
||||||
onClick={isConnected ? handleDisconnect : requestTunnel}
|
onClick={isConnected ? handleDisconnect : requestTunnel}
|
||||||
>
|
>
|
||||||
{isConnected ? "Disconnect" : "Connect"}
|
{isConnected ? "Disconnect" : "Establish"}
|
||||||
</Button>
|
</Button>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
{sessionChips.map((chip) => (
|
{sessionChips.map((chip) => (
|
||||||
@@ -510,7 +466,7 @@ export default function ReverseTunnelPowershell({ device }) {
|
|||||||
size="small"
|
size="small"
|
||||||
value={input}
|
value={input}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
placeholder={isConnected ? "Enter PowerShell command and press Enter" : "Connect to start sending commands"}
|
placeholder={isConnected ? "Enter PowerShell command and press Enter" : "Establish to start sending commands"}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
|||||||
@@ -1,552 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
LinearProgress,
|
|
||||||
} from "@mui/material";
|
|
||||||
import {
|
|
||||||
DesktopWindowsRounded as DesktopIcon,
|
|
||||||
PlayArrowRounded as PlayIcon,
|
|
||||||
StopRounded as StopIcon,
|
|
||||||
LinkRounded as LinkIcon,
|
|
||||||
LanRounded as IpIcon,
|
|
||||||
} from "@mui/icons-material";
|
|
||||||
import Guacamole from "guacamole-common-js";
|
|
||||||
|
|
||||||
const MAGIC_UI = {
|
|
||||||
panelBorder: "rgba(148, 163, 184, 0.35)",
|
|
||||||
textMuted: "#94a3b8",
|
|
||||||
textBright: "#e2e8f0",
|
|
||||||
accentA: "#7dd3fc",
|
|
||||||
accentB: "#c084fc",
|
|
||||||
accentC: "#34d399",
|
|
||||||
};
|
|
||||||
|
|
||||||
const gradientButtonSx = {
|
|
||||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
|
||||||
color: "#0b1220",
|
|
||||||
borderRadius: 999,
|
|
||||||
textTransform: "none",
|
|
||||||
px: 2.2,
|
|
||||||
minWidth: 120,
|
|
||||||
"&:hover": {
|
|
||||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const PROTOCOLS = [{ value: "rdp", label: "RDP" }];
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
function normalizeText(value) {
|
|
||||||
if (value == null) return "";
|
|
||||||
try {
|
|
||||||
return String(value).trim();
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReverseTunnelRdp({ device }) {
|
|
||||||
const [sessionState, setSessionState] = useState("idle");
|
|
||||||
const [statusMessage, setStatusMessage] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [tunnel, setTunnel] = useState(null);
|
|
||||||
const [protocol, setProtocol] = useState("rdp");
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const displayRef = useRef(null);
|
|
||||||
const clientRef = useRef(null);
|
|
||||||
const tunnelRef = useRef(null);
|
|
||||||
const mouseRef = useRef(null);
|
|
||||||
const agentIdRef = useRef("");
|
|
||||||
const tunnelIdRef = useRef("");
|
|
||||||
const bumpTimerRef = useRef(null);
|
|
||||||
|
|
||||||
const agentId = useMemo(() => {
|
|
||||||
return (
|
|
||||||
normalizeText(device?.agent_id) ||
|
|
||||||
normalizeText(device?.agentId) ||
|
|
||||||
normalizeText(device?.agent_guid) ||
|
|
||||||
normalizeText(device?.agentGuid) ||
|
|
||||||
normalizeText(device?.id) ||
|
|
||||||
normalizeText(device?.guid) ||
|
|
||||||
normalizeText(device?.summary?.agent_id) ||
|
|
||||||
""
|
|
||||||
);
|
|
||||||
}, [device]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
agentIdRef.current = agentId;
|
|
||||||
}, [agentId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
tunnelIdRef.current = tunnel?.tunnel_id || "";
|
|
||||||
}, [tunnel?.tunnel_id]);
|
|
||||||
|
|
||||||
const notifyAgentOnboarding = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await fetch("/api/notifications/notify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: "Agent Onboarding Underway",
|
|
||||||
message:
|
|
||||||
"Please wait for the agent to finish onboarding into Borealis. It takes about 1 minute to finish the process.",
|
|
||||||
icon: "info",
|
|
||||||
variant: "info",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* ignore notification transport errors */
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAgentOnboarding = useCallback(async () => {
|
|
||||||
await notifyAgentOnboarding();
|
|
||||||
setStatusMessage("Agent Onboarding Underway.");
|
|
||||||
setSessionState("idle");
|
|
||||||
setTunnel(null);
|
|
||||||
}, [notifyAgentOnboarding]);
|
|
||||||
|
|
||||||
const teardownDisplay = useCallback(() => {
|
|
||||||
try {
|
|
||||||
const client = clientRef.current;
|
|
||||||
if (client) {
|
|
||||||
client.disconnect();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
clientRef.current = null;
|
|
||||||
tunnelRef.current = null;
|
|
||||||
mouseRef.current = null;
|
|
||||||
const host = displayRef.current;
|
|
||||||
if (host) {
|
|
||||||
host.innerHTML = "";
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopTunnel = useCallback(async (reason = "operator_disconnect") => {
|
|
||||||
const currentAgentId = agentIdRef.current;
|
|
||||||
if (!currentAgentId) return;
|
|
||||||
const currentTunnelId = tunnelIdRef.current;
|
|
||||||
try {
|
|
||||||
await fetch("/api/tunnel/disconnect", {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ agent_id: currentAgentId, tunnel_id: currentTunnelId, reason }),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearBumpTimer = useCallback(() => {
|
|
||||||
if (bumpTimerRef.current) {
|
|
||||||
clearInterval(bumpTimerRef.current);
|
|
||||||
bumpTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startBumpTimer = useCallback(
|
|
||||||
(currentAgentId) => {
|
|
||||||
clearBumpTimer();
|
|
||||||
if (!currentAgentId) return;
|
|
||||||
bumpTimerRef.current = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/tunnel/connect/status?agent_id=${encodeURIComponent(currentAgentId)}&bump=1`);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
},
|
|
||||||
[clearBumpTimer]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDisconnect = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setStatusMessage("");
|
|
||||||
clearBumpTimer();
|
|
||||||
try {
|
|
||||||
teardownDisplay();
|
|
||||||
await stopTunnel("operator_disconnect");
|
|
||||||
} finally {
|
|
||||||
setTunnel(null);
|
|
||||||
setSessionState("idle");
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [clearBumpTimer, stopTunnel, teardownDisplay]);
|
|
||||||
|
|
||||||
const scaleToFit = useCallback(() => {
|
|
||||||
const client = clientRef.current;
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!client || !container) return;
|
|
||||||
const display = client.getDisplay();
|
|
||||||
const displayWidth = display.getWidth();
|
|
||||||
const displayHeight = display.getHeight();
|
|
||||||
const bounds = container.getBoundingClientRect();
|
|
||||||
if (!displayWidth || !displayHeight || !bounds.width || !bounds.height) return;
|
|
||||||
const scale = Math.min(bounds.width / displayWidth, bounds.height / displayHeight);
|
|
||||||
display.scale(scale);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => scaleToFit();
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
let observer = null;
|
|
||||||
if (typeof ResizeObserver !== "undefined" && containerRef.current) {
|
|
||||||
observer = new ResizeObserver(() => scaleToFit());
|
|
||||||
observer.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
if (observer) observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [scaleToFit]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
clearBumpTimer();
|
|
||||||
teardownDisplay();
|
|
||||||
stopTunnel("component_unmount");
|
|
||||||
};
|
|
||||||
}, [clearBumpTimer, stopTunnel, teardownDisplay]);
|
|
||||||
|
|
||||||
const requestTunnel = useCallback(async () => {
|
|
||||||
if (!agentId) {
|
|
||||||
setStatusMessage("Agent ID is required to connect.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setStatusMessage("");
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
const readinessResp = await fetch(`/api/tunnel/status?agent_id=${encodeURIComponent(agentId)}`);
|
|
||||||
const readinessData = await readinessResp.json().catch(() => ({}));
|
|
||||||
if (readinessResp.ok && readinessData?.agent_socket !== true) {
|
|
||||||
await handleAgentOnboarding();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// best-effort readiness check
|
|
||||||
}
|
|
||||||
|
|
||||||
setSessionState("connecting");
|
|
||||||
const resp = await fetch("/api/tunnel/connect", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ agent_id: agentId }),
|
|
||||||
});
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) {
|
|
||||||
const detail = data?.detail ? `: ${data.detail}` : "";
|
|
||||||
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
|
|
||||||
}
|
|
||||||
tunnelIdRef.current = data?.tunnel_id || "";
|
|
||||||
|
|
||||||
const waitForTunnelReady = async () => {
|
|
||||||
const deadline = Date.now() + 60000;
|
|
||||||
let lastError = "";
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const statusResp = await fetch(
|
|
||||||
`/api/tunnel/connect/status?agent_id=${encodeURIComponent(agentId)}&bump=1`
|
|
||||||
);
|
|
||||||
const statusData = await statusResp.json().catch(() => ({}));
|
|
||||||
if (statusData?.error === "agent_socket_missing" || (statusResp.ok && statusData?.agent_socket === false)) {
|
|
||||||
await handleAgentOnboarding();
|
|
||||||
await stopTunnel("agent_onboarding_pending");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (statusResp.ok && statusData?.status === "up") {
|
|
||||||
const agentSocket = statusData?.agent_socket;
|
|
||||||
const agentReady = agentSocket === undefined ? true : Boolean(agentSocket);
|
|
||||||
if (agentReady) {
|
|
||||||
return statusData;
|
|
||||||
}
|
|
||||||
setStatusMessage("Waiting for agent VPN socket to register...");
|
|
||||||
} else if (statusData?.error) {
|
|
||||||
lastError = statusData.error;
|
|
||||||
}
|
|
||||||
await sleep(2000);
|
|
||||||
}
|
|
||||||
throw new Error(lastError || "Tunnel not ready");
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusData = await waitForTunnelReady();
|
|
||||||
if (!statusData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
setTunnel({ ...data, ...statusData });
|
|
||||||
startBumpTimer(agentId);
|
|
||||||
return statusData;
|
|
||||||
} catch (err) {
|
|
||||||
setSessionState("error");
|
|
||||||
setStatusMessage(String(err.message || err));
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [agentId, handleAgentOnboarding, startBumpTimer, stopTunnel]);
|
|
||||||
|
|
||||||
const openRdpSession = useCallback(
|
|
||||||
async () => {
|
|
||||||
const currentAgentId = agentIdRef.current;
|
|
||||||
if (!currentAgentId) return;
|
|
||||||
const payload = {
|
|
||||||
agent_id: currentAgentId,
|
|
||||||
protocol,
|
|
||||||
username: username.trim(),
|
|
||||||
password,
|
|
||||||
};
|
|
||||||
const resp = await fetch("/api/rdp/session", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await resp.json().catch(() => ({}));
|
|
||||||
if (!resp.ok) {
|
|
||||||
const detail = data?.detail ? `: ${data.detail}` : "";
|
|
||||||
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
|
|
||||||
}
|
|
||||||
const token = data?.token;
|
|
||||||
const wsUrl = data?.ws_url;
|
|
||||||
if (!token || !wsUrl) {
|
|
||||||
throw new Error("RDP session unavailable.");
|
|
||||||
}
|
|
||||||
const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`;
|
|
||||||
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
|
||||||
const client = new Guacamole.Client(tunnel);
|
|
||||||
const displayHost = displayRef.current;
|
|
||||||
|
|
||||||
tunnel.onerror = (status) => {
|
|
||||||
setStatusMessage(status?.message || "RDP tunnel error.");
|
|
||||||
};
|
|
||||||
client.onerror = (status) => {
|
|
||||||
setStatusMessage(status?.message || "RDP client error.");
|
|
||||||
};
|
|
||||||
client.onstatechange = (state) => {
|
|
||||||
if (state === Guacamole.Client.State.CONNECTED) {
|
|
||||||
setSessionState("connected");
|
|
||||||
setStatusMessage("");
|
|
||||||
} else if (state === Guacamole.Client.State.DISCONNECTED) {
|
|
||||||
setSessionState("idle");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
client.onresize = () => {
|
|
||||||
scaleToFit();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (displayHost) {
|
|
||||||
displayHost.innerHTML = "";
|
|
||||||
displayHost.appendChild(client.getDisplay().getElement());
|
|
||||||
}
|
|
||||||
|
|
||||||
const mouse = new Guacamole.Mouse(client.getDisplay().getElement());
|
|
||||||
mouse.onmousemove = (state) => client.sendMouseState(state);
|
|
||||||
mouse.onmousedown = (state) => client.sendMouseState(state);
|
|
||||||
mouse.onmouseup = (state) => client.sendMouseState(state);
|
|
||||||
|
|
||||||
clientRef.current = client;
|
|
||||||
tunnelRef.current = tunnel;
|
|
||||||
mouseRef.current = mouse;
|
|
||||||
client.connect();
|
|
||||||
scaleToFit();
|
|
||||||
setStatusMessage("Connecting to RDP...");
|
|
||||||
},
|
|
||||||
[password, protocol, scaleToFit, username]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
|
||||||
if (sessionState === "connected") return;
|
|
||||||
setStatusMessage("");
|
|
||||||
setSessionState("connecting");
|
|
||||||
try {
|
|
||||||
const tunnelReady = await requestTunnel();
|
|
||||||
if (!tunnelReady) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await openRdpSession();
|
|
||||||
} catch (err) {
|
|
||||||
setSessionState("error");
|
|
||||||
setStatusMessage(String(err.message || err));
|
|
||||||
}
|
|
||||||
}, [openRdpSession, requestTunnel, sessionState]);
|
|
||||||
|
|
||||||
const isConnected = sessionState === "connected";
|
|
||||||
const sessionChips = [
|
|
||||||
tunnel?.tunnel_id
|
|
||||||
? {
|
|
||||||
label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`,
|
|
||||||
color: MAGIC_UI.accentB,
|
|
||||||
icon: <LinkIcon sx={{ fontSize: 18 }} />,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
tunnel?.virtual_ip
|
|
||||||
? {
|
|
||||||
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
|
|
||||||
color: MAGIC_UI.accentA,
|
|
||||||
icon: <IpIcon sx={{ fontSize: 18 }} />,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
|
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", md: "center" }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
|
|
||||||
sx={gradientButtonSx}
|
|
||||||
disabled={loading || (!isConnected && !agentId)}
|
|
||||||
onClick={isConnected ? handleDisconnect : handleConnect}
|
|
||||||
>
|
|
||||||
{isConnected ? "Disconnect" : "Connect"}
|
|
||||||
</Button>
|
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", alignItems: "center" }}>
|
|
||||||
<FormControl
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
minWidth: 140,
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "rgba(12,18,35,0.9)",
|
|
||||||
color: MAGIC_UI.textBright,
|
|
||||||
borderRadius: 2,
|
|
||||||
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
|
|
||||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InputLabel>Protocol</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Protocol"
|
|
||||||
value={protocol}
|
|
||||||
onChange={(e) => setProtocol(e.target.value)}
|
|
||||||
disabled={isConnected}
|
|
||||||
>
|
|
||||||
{PROTOCOLS.map((item) => (
|
|
||||||
<MenuItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
label="Username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
disabled={isConnected}
|
|
||||||
sx={{
|
|
||||||
minWidth: 180,
|
|
||||||
input: { color: MAGIC_UI.textBright },
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "rgba(12,18,35,0.9)",
|
|
||||||
borderRadius: 2,
|
|
||||||
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
|
|
||||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
type="password"
|
|
||||||
label="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={isConnected}
|
|
||||||
sx={{
|
|
||||||
minWidth: 180,
|
|
||||||
input: { color: MAGIC_UI.textBright },
|
|
||||||
"& .MuiOutlinedInput-root": {
|
|
||||||
backgroundColor: "rgba(12,18,35,0.9)",
|
|
||||||
borderRadius: 2,
|
|
||||||
"& fieldset": { borderColor: "rgba(148,163,184,0.45)" },
|
|
||||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
|
||||||
},
|
|
||||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
{sessionChips.map((chip) => (
|
|
||||||
<Chip
|
|
||||||
key={chip.label}
|
|
||||||
icon={chip.icon}
|
|
||||||
label={chip.label}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 999,
|
|
||||||
color: chip.color,
|
|
||||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
|
||||||
backgroundColor: "rgba(8,12,24,0.65)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexGrow: 1,
|
|
||||||
minHeight: 320,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
borderRadius: 3,
|
|
||||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
|
||||||
background:
|
|
||||||
"linear-gradient(145deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 20%, rgba(125,211,252,0.08), transparent 35%)",
|
|
||||||
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
|
|
||||||
<Box
|
|
||||||
ref={containerRef}
|
|
||||||
sx={{
|
|
||||||
flexGrow: 1,
|
|
||||||
position: "relative",
|
|
||||||
backgroundColor: "rgba(2,6,20,0.9)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box ref={displayRef} sx={{ width: "100%", height: "100%" }} />
|
|
||||||
{!isConnected ? (
|
|
||||||
<Stack spacing={1} sx={{ position: "absolute", alignItems: "center" }}>
|
|
||||||
<DesktopIcon sx={{ color: MAGIC_UI.accentA, fontSize: 40 }} />
|
|
||||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
|
||||||
Connect to start the remote desktop session.
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack spacing={0.3} sx={{ mt: 1 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
|
||||||
Session: {isConnected ? "Active" : sessionState}
|
|
||||||
</Typography>
|
|
||||||
{statusMessage ? (
|
|
||||||
<Typography variant="body2" sx={{ color: sessionState === "error" ? "#ff7b89" : MAGIC_UI.textMuted }}>
|
|
||||||
{statusMessage}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
351
Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx
Normal file
351
Data/Engine/web-interface/src/Devices/ReverseTunnel/VNC.jsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
LinearProgress,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
DesktopWindowsRounded as DesktopIcon,
|
||||||
|
PlayArrowRounded as PlayIcon,
|
||||||
|
StopRounded as StopIcon,
|
||||||
|
LinkRounded as LinkIcon,
|
||||||
|
LanRounded as IpIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import RFB from "@novnc/novnc/lib/rfb";
|
||||||
|
|
||||||
|
const MAGIC_UI = {
|
||||||
|
panelBorder: "rgba(148, 163, 184, 0.35)",
|
||||||
|
textMuted: "#94a3b8",
|
||||||
|
textBright: "#e2e8f0",
|
||||||
|
accentA: "#7dd3fc",
|
||||||
|
accentB: "#c084fc",
|
||||||
|
accentC: "#34d399",
|
||||||
|
};
|
||||||
|
|
||||||
|
const gradientButtonSx = {
|
||||||
|
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||||
|
color: "#0b1220",
|
||||||
|
borderRadius: 999,
|
||||||
|
textTransform: "none",
|
||||||
|
px: 2.2,
|
||||||
|
minWidth: 120,
|
||||||
|
"&:hover": {
|
||||||
|
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
try {
|
||||||
|
return String(value).trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReverseTunnelVnc({ device }) {
|
||||||
|
const [sessionState, setSessionState] = useState("idle");
|
||||||
|
const [statusMessage, setStatusMessage] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tunnel, setTunnel] = useState(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const displayRef = useRef(null);
|
||||||
|
const rfbRef = useRef(null);
|
||||||
|
const agentIdRef = useRef("");
|
||||||
|
|
||||||
|
const agentId = useMemo(() => {
|
||||||
|
return (
|
||||||
|
normalizeText(device?.agent_id) ||
|
||||||
|
normalizeText(device?.agentId) ||
|
||||||
|
normalizeText(device?.agent_guid) ||
|
||||||
|
normalizeText(device?.agentGuid) ||
|
||||||
|
normalizeText(device?.id) ||
|
||||||
|
normalizeText(device?.guid) ||
|
||||||
|
normalizeText(device?.summary?.agent_id) ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}, [device]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
agentIdRef.current = agentId;
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
|
||||||
|
const notifyAgentOnboarding = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/notifications/notify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Agent Onboarding Underway",
|
||||||
|
message:
|
||||||
|
"Please wait for the agent to finish onboarding into Borealis. It takes about 1 minute to finish the process.",
|
||||||
|
icon: "info",
|
||||||
|
variant: "info",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore notification transport errors */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAgentOnboarding = useCallback(async () => {
|
||||||
|
await notifyAgentOnboarding();
|
||||||
|
setStatusMessage("Agent Onboarding Underway.");
|
||||||
|
setSessionState("idle");
|
||||||
|
setTunnel(null);
|
||||||
|
}, [notifyAgentOnboarding]);
|
||||||
|
|
||||||
|
const teardownDisplay = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const client = rfbRef.current;
|
||||||
|
if (client) {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
rfbRef.current = null;
|
||||||
|
const host = displayRef.current;
|
||||||
|
if (host) {
|
||||||
|
host.innerHTML = "";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disconnectVnc = useCallback(async (reason = "operator_disconnect") => {
|
||||||
|
const currentAgentId = agentIdRef.current;
|
||||||
|
if (!currentAgentId) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/vnc/disconnect", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ agent_id: currentAgentId, reason }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDisconnect = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setStatusMessage("");
|
||||||
|
try {
|
||||||
|
teardownDisplay();
|
||||||
|
await disconnectVnc("operator_disconnect");
|
||||||
|
} finally {
|
||||||
|
setTunnel(null);
|
||||||
|
setSessionState("idle");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [disconnectVnc, teardownDisplay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
teardownDisplay();
|
||||||
|
disconnectVnc("component_unmount");
|
||||||
|
};
|
||||||
|
}, [disconnectVnc, teardownDisplay]);
|
||||||
|
|
||||||
|
const requestTunnel = useCallback(async () => {
|
||||||
|
if (!agentId) {
|
||||||
|
setStatusMessage("Agent ID is required to establish.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setStatusMessage("");
|
||||||
|
try {
|
||||||
|
setSessionState("connecting");
|
||||||
|
const resp = await fetch("/api/vnc/establish", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ agent_id: agentId }),
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (data?.error === "agent_socket_missing") {
|
||||||
|
await handleAgentOnboarding();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const detail = data?.detail ? `: ${data.detail}` : "";
|
||||||
|
throw new Error(`${data?.error || `HTTP ${resp.status}`}${detail}`);
|
||||||
|
}
|
||||||
|
setTunnel({
|
||||||
|
tunnel_id: data?.tunnel_id,
|
||||||
|
virtual_ip: data?.virtual_ip,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
setSessionState("error");
|
||||||
|
setStatusMessage(String(err.message || err));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [agentId, handleAgentOnboarding]);
|
||||||
|
|
||||||
|
const openVncSession = useCallback(async (data) => {
|
||||||
|
const token = data?.token;
|
||||||
|
const wsUrl = data?.ws_url;
|
||||||
|
const vncPassword = data?.vnc_password || "";
|
||||||
|
if (!token || !wsUrl) {
|
||||||
|
throw new Error("VNC session unavailable.");
|
||||||
|
}
|
||||||
|
const tunnelUrl = `${wsUrl}?token=${encodeURIComponent(token)}`;
|
||||||
|
const displayHost = displayRef.current;
|
||||||
|
if (!displayHost) {
|
||||||
|
throw new Error("VNC display container missing.");
|
||||||
|
}
|
||||||
|
displayHost.innerHTML = "";
|
||||||
|
|
||||||
|
const rfb = new RFB(displayHost, tunnelUrl, {
|
||||||
|
credentials: { password: vncPassword },
|
||||||
|
});
|
||||||
|
rfb.scaleViewport = true;
|
||||||
|
rfb.resizeSession = true;
|
||||||
|
rfb.clipViewport = true;
|
||||||
|
|
||||||
|
rfb.addEventListener("connect", () => {
|
||||||
|
setSessionState("connected");
|
||||||
|
setStatusMessage("");
|
||||||
|
});
|
||||||
|
rfb.addEventListener("disconnect", () => {
|
||||||
|
setSessionState("idle");
|
||||||
|
rfbRef.current = null;
|
||||||
|
});
|
||||||
|
rfb.addEventListener("securityfailure", (evt) => {
|
||||||
|
const detail = evt?.detail?.reason ? ` (${evt.detail.reason})` : "";
|
||||||
|
setSessionState("error");
|
||||||
|
setStatusMessage(`VNC authentication failed${detail}.`);
|
||||||
|
});
|
||||||
|
rfb.addEventListener("credentialsrequired", () => {
|
||||||
|
try {
|
||||||
|
rfb.sendCredentials({ password: vncPassword });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rfbRef.current = rfb;
|
||||||
|
setStatusMessage("Establishing VNC...");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConnect = useCallback(async () => {
|
||||||
|
if (sessionState === "connected") return;
|
||||||
|
setStatusMessage("");
|
||||||
|
setSessionState("connecting");
|
||||||
|
try {
|
||||||
|
const sessionData = await requestTunnel();
|
||||||
|
if (!sessionData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openVncSession(sessionData);
|
||||||
|
} catch (err) {
|
||||||
|
setSessionState("error");
|
||||||
|
setStatusMessage(String(err.message || err));
|
||||||
|
}
|
||||||
|
}, [openVncSession, requestTunnel, sessionState]);
|
||||||
|
|
||||||
|
const isConnected = sessionState === "connected";
|
||||||
|
const sessionChips = [
|
||||||
|
tunnel?.tunnel_id
|
||||||
|
? {
|
||||||
|
label: `Tunnel ${tunnel.tunnel_id.slice(0, 8)}`,
|
||||||
|
color: MAGIC_UI.accentB,
|
||||||
|
icon: <LinkIcon sx={{ fontSize: 18 }} />,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
tunnel?.virtual_ip
|
||||||
|
? {
|
||||||
|
label: `IP ${String(tunnel.virtual_ip).split("/")[0]}`,
|
||||||
|
color: MAGIC_UI.accentA,
|
||||||
|
icon: <IpIcon sx={{ fontSize: 18 }} />,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, flexGrow: 1, minHeight: 0 }}>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={1.5} alignItems={{ xs: "flex-start", md: "center" }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={isConnected ? <StopIcon /> : <PlayIcon />}
|
||||||
|
sx={gradientButtonSx}
|
||||||
|
disabled={loading || (!isConnected && !agentId)}
|
||||||
|
onClick={isConnected ? handleDisconnect : handleConnect}
|
||||||
|
>
|
||||||
|
{isConnected ? "Disconnect" : "Establish"}
|
||||||
|
</Button>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
{sessionChips.map((chip) => (
|
||||||
|
<Chip
|
||||||
|
key={chip.label}
|
||||||
|
icon={chip.icon}
|
||||||
|
label={chip.label}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 999,
|
||||||
|
color: chip.color,
|
||||||
|
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||||
|
backgroundColor: "rgba(8,12,24,0.65)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 320,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||||
|
background:
|
||||||
|
"linear-gradient(145deg, rgba(8,12,24,0.94), rgba(10,16,30,0.9)), radial-gradient(circle at 20% 20%, rgba(125,211,252,0.08), transparent 35%)",
|
||||||
|
boxShadow: "0 25px 80px rgba(2,6,23,0.85)",
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? <LinearProgress color="info" sx={{ height: 3 }} /> : null}
|
||||||
|
<Box
|
||||||
|
ref={containerRef}
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
position: "relative",
|
||||||
|
backgroundColor: "rgba(2,6,20,0.9)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box ref={displayRef} sx={{ width: "100%", height: "100%" }} />
|
||||||
|
{!isConnected ? (
|
||||||
|
<Stack spacing={1} sx={{ position: "absolute", alignItems: "center" }}>
|
||||||
|
<DesktopIcon sx={{ color: MAGIC_UI.accentA, fontSize: 40 }} />
|
||||||
|
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||||
|
Establish to start the VNC session.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack spacing={0.3} sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||||
|
Session: {isConnected ? "Active" : sessionState}
|
||||||
|
</Typography>
|
||||||
|
{statusMessage ? (
|
||||||
|
<Typography variant="body2" sx={{ color: sessionState === "error" ? "#ff7b89" : MAGIC_UI.textMuted }}>
|
||||||
|
{statusMessage}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,14 @@ const httpsOptions = certPath && keyPath
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
esbuild: {
|
||||||
|
target: "es2022",
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
target: "es2022",
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
open: true,
|
||||||
host: true,
|
host: true,
|
||||||
@@ -69,6 +77,7 @@ export default defineConfig({
|
|||||||
outDir: 'build',
|
outDir: 'build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
chunkSizeWarningLimit: 1000,
|
chunkSizeWarningLimit: 1000,
|
||||||
|
target: 'es2022',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
// split each npm package into its own chunk
|
// split each npm package into its own chunk
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Describe the Borealis agent runtime, its roles, service modes, and how it commun
|
|||||||
- `role_DeviceAudit.py` (ROLE_NAME: `device_audit`) - inventory and audit data capture.
|
- `role_DeviceAudit.py` (ROLE_NAME: `device_audit`) - inventory and audit data capture.
|
||||||
- `role_Macro.py` (ROLE_NAME: `macro`) - macro automation.
|
- `role_Macro.py` (ROLE_NAME: `macro`) - macro automation.
|
||||||
- `role_PlaybookExec_SYSTEM.py` (ROLE_NAME: `playbook_exec_system`) - Ansible playbook runner (unfinished).
|
- `role_PlaybookExec_SYSTEM.py` (ROLE_NAME: `playbook_exec_system`) - Ansible playbook runner (unfinished).
|
||||||
- `role_RDP.py` (ROLE_NAME: `RDP`) - RDP readiness hooks.
|
- `role_VNC.py` (ROLE_NAME: `VNC`) - on-demand UltraVNC server lifecycle.
|
||||||
- `role_RemotePowershell.py` (ROLE_NAME: `RemotePowershell`) - TCP PowerShell server over WireGuard.
|
- `role_RemotePowershell.py` (ROLE_NAME: `RemotePowershell`) - TCP PowerShell server over WireGuard.
|
||||||
- `role_Screenshot.py` (ROLE_NAME: `screenshot`) - screenshot capture.
|
- `role_Screenshot.py` (ROLE_NAME: `screenshot`) - screenshot capture.
|
||||||
- `role_ScriptExec_CURRENTUSER.py` (ROLE_NAME: `script_exec_currentuser`) - interactive PowerShell execution.
|
- `role_ScriptExec_CURRENTUSER.py` (ROLE_NAME: `script_exec_currentuser`) - interactive PowerShell execution.
|
||||||
@@ -34,6 +34,7 @@ Describe the Borealis agent runtime, its roles, service modes, and how it commun
|
|||||||
- `POST /api/agent/heartbeat` (Device Authenticated) - heartbeat + metrics.
|
- `POST /api/agent/heartbeat` (Device Authenticated) - heartbeat + metrics.
|
||||||
- `POST /api/agent/details` (Device Authenticated) - hardware/inventory payloads.
|
- `POST /api/agent/details` (Device Authenticated) - hardware/inventory payloads.
|
||||||
- `POST /api/agent/script/request` (Device Authenticated) - request work or receive idle signal.
|
- `POST /api/agent/script/request` (Device Authenticated) - request work or receive idle signal.
|
||||||
|
- `POST /api/agent/vpn/ensure` (Device Authenticated) - persistent WireGuard tunnel bootstrap.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
- [Security and Trust](security-and-trust.md)
|
- [Security and Trust](security-and-trust.md)
|
||||||
@@ -66,8 +67,10 @@ Describe the Borealis agent runtime, its roles, service modes, and how it commun
|
|||||||
- `AgentHttpClient.ensure_authenticated()` handles enrollment and refresh.
|
- `AgentHttpClient.ensure_authenticated()` handles enrollment and refresh.
|
||||||
- Socket.IO is used for:
|
- Socket.IO is used for:
|
||||||
- `quick_job_run` dispatch (script execution payloads).
|
- `quick_job_run` dispatch (script execution payloads).
|
||||||
- `vpn_tunnel_start` and `vpn_tunnel_stop` (WireGuard lifecycle).
|
- `vpn_tunnel_start` (WireGuard lifecycle; tunnels are persistent and ignore stop events).
|
||||||
- `connect_agent` registration (agent socket registry).
|
- `connect_agent` registration (agent socket registry).
|
||||||
|
- WireGuard tunnels are ensured via `POST /api/agent/vpn/ensure` on boot and refreshed periodically.
|
||||||
|
- The ensure loop re-establishes the tunnel automatically after network hiccups.
|
||||||
|
|
||||||
### Token storage
|
### Token storage
|
||||||
- Refresh tokens are stored encrypted (DPAPI on Windows) in `refresh.token`.
|
- Refresh tokens are stored encrypted (DPAPI on Windows) in `refresh.token`.
|
||||||
@@ -89,7 +92,8 @@ Describe the Borealis agent runtime, its roles, service modes, and how it commun
|
|||||||
- Confirm `quick_job_run` events and the correct role context.
|
- Confirm `quick_job_run` events and the correct role context.
|
||||||
- Verify signatures with `signature_utils` logs.
|
- Verify signatures with `signature_utils` logs.
|
||||||
- If VPN fails:
|
- If VPN fails:
|
||||||
- Check agent WireGuard role logs and ensure the Engine emitted `vpn_tunnel_start`.
|
- Check agent WireGuard role logs and confirm `/api/agent/vpn/ensure` succeeds.
|
||||||
|
- Ensure the Engine has an active tunnel session and the WireGuard service is running.
|
||||||
|
|
||||||
### Borealis Agent Codex (Full)
|
### Borealis Agent Codex (Full)
|
||||||
Use this section for agent-only work (Borealis agent runtime under `Data/Agent` -> `/Agent`). Shared guidance is consolidated in `ui-and-notifications.md` and the Engine runtime notes.
|
Use this section for agent-only work (Borealis agent runtime under `Data/Agent` -> `/Agent`). Shared guidance is consolidated in `ui-and-notifications.md` and the Engine runtime notes.
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Provide a consolidated, human-readable list of Borealis Engine API endpoints gro
|
|||||||
- `POST /api/agent/heartbeat` (Device Authenticated) - heartbeat + metrics.
|
- `POST /api/agent/heartbeat` (Device Authenticated) - heartbeat + metrics.
|
||||||
- `POST /api/agent/details` (Device Authenticated) - full hardware/inventory payload.
|
- `POST /api/agent/details` (Device Authenticated) - full hardware/inventory payload.
|
||||||
- `POST /api/agent/script/request` (Device Authenticated) - request work or idle signal.
|
- `POST /api/agent/script/request` (Device Authenticated) - request work or idle signal.
|
||||||
|
- `POST /api/agent/vpn/ensure` (Device Authenticated) - persistent WireGuard tunnel bootstrap.
|
||||||
- `GET /api/agents` (Token Authenticated) - list online collectors by hostname/context.
|
- `GET /api/agents` (Token Authenticated) - list online collectors by hostname/context.
|
||||||
- `GET /api/devices` (Token Authenticated) - device summary list.
|
- `GET /api/devices` (Token Authenticated) - device summary list.
|
||||||
- `GET /api/devices/<guid>` (Token Authenticated) - device summary by GUID.
|
- `GET /api/devices/<guid>` (Token Authenticated) - device summary by GUID.
|
||||||
@@ -102,14 +103,18 @@ Provide a consolidated, human-readable list of Borealis Engine API endpoints gro
|
|||||||
- `POST /api/notifications/notify` (Token Authenticated) - broadcast toast notification.
|
- `POST /api/notifications/notify` (Token Authenticated) - broadcast toast notification.
|
||||||
|
|
||||||
### VPN and Remote Access
|
### VPN and Remote Access
|
||||||
- `POST /api/tunnel/connect` (Token Authenticated) - start WireGuard tunnel.
|
- `POST /api/tunnel/connect` (Token Authenticated) - ensure WireGuard tunnel material for an agent.
|
||||||
- `GET /api/tunnel/status` (Token Authenticated) - tunnel status by agent.
|
- `GET /api/tunnel/status` (Token Authenticated) - tunnel status by agent.
|
||||||
- `GET /api/tunnel/connect/status` (Token Authenticated) - alias for status.
|
|
||||||
- `GET /api/tunnel/active` (Token Authenticated) - list active tunnels.
|
- `GET /api/tunnel/active` (Token Authenticated) - list active tunnels.
|
||||||
- `DELETE /api/tunnel/disconnect` (Token Authenticated) - stop tunnel.
|
|
||||||
|
|
||||||
### RDP
|
### VNC
|
||||||
- `POST /api/rdp/session` (Token Authenticated) - issue Guacamole RDP session token.
|
- `POST /api/vnc/establish` (Token Authenticated) - establish VNC session token.
|
||||||
|
- `POST /api/vnc/disconnect` (Token Authenticated) - disconnect VNC session.
|
||||||
|
- `POST /api/vnc/session` (Token Authenticated) - legacy alias for establish.
|
||||||
|
|
||||||
|
### Remote Shell
|
||||||
|
- `POST /api/shell/establish` (Token Authenticated) - establish remote shell session.
|
||||||
|
- `POST /api/shell/disconnect` (Token Authenticated) - disconnect remote shell session.
|
||||||
|
|
||||||
### Server Info and Logs
|
### Server Info and Logs
|
||||||
- `GET /api/server/time` (Operator Session) - server clock.
|
- `GET /api/server/time` (Operator Session) - server clock.
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ Explain how Borealis is structured and how the core components interact end to e
|
|||||||
- Agent: Python runtime that enrolls, reports inventory, executes scripts, and opens VPN tunnels.
|
- Agent: Python runtime that enrolls, reports inventory, executes scripts, and opens VPN tunnels.
|
||||||
- SQLite database: stores devices, approvals, schedules, activity history, tokens, and configuration records.
|
- SQLite database: stores devices, approvals, schedules, activity history, tokens, and configuration records.
|
||||||
- Assemblies: script definitions stored in SQLite domains with payload artifacts on disk.
|
- Assemblies: script definitions stored in SQLite domains with payload artifacts on disk.
|
||||||
- Remote access: WireGuard reverse VPN, remote PowerShell, and Guacamole-backed RDP proxy.
|
- Remote access: WireGuard reverse VPN, remote PowerShell, and VNC via noVNC.
|
||||||
|
|
||||||
## How the Pieces Talk
|
## How the Pieces Talk
|
||||||
- Enrollment: agent calls `/api/agent/enroll/request` and `/api/agent/enroll/poll`, operator approves, Engine issues tokens and cert bundle.
|
- Enrollment: agent calls `/api/agent/enroll/request` and `/api/agent/enroll/poll`, operator approves, Engine issues tokens and cert bundle.
|
||||||
- Inventory: agent posts `/api/agent/heartbeat` and `/api/agent/details`, Engine updates device records.
|
- Inventory: agent posts `/api/agent/heartbeat` and `/api/agent/details`, Engine updates device records.
|
||||||
- Quick jobs: operator calls `/api/scripts/quick_run`, Engine emits `quick_job_run` over Socket.IO, agent executes and returns `quick_job_result`.
|
- Quick jobs: operator calls `/api/scripts/quick_run`, Engine emits `quick_job_run` over Socket.IO, agent executes and returns `quick_job_result`.
|
||||||
- Scheduled jobs: scheduler reads jobs from DB, resolves targets (including filters), then emits quick jobs.
|
- Scheduled jobs: scheduler reads jobs from DB, resolves targets (including filters), then emits quick jobs.
|
||||||
- VPN tunnels: operator calls `/api/tunnel/connect`, Engine emits `vpn_tunnel_start`, agent starts WireGuard client.
|
- VPN tunnels: agent calls `/api/agent/vpn/ensure`, Engine emits `vpn_tunnel_start`, agent keeps WireGuard client online.
|
||||||
- Remote shell: UI uses Socket.IO `vpn_shell_*` events, Engine bridges to agent TCP shell over WireGuard.
|
- Remote shell: UI uses Socket.IO `vpn_shell_*` events, Engine bridges to agent TCP shell over WireGuard.
|
||||||
- RDP: operator calls `/api/rdp/session`, Engine creates a one-time token and proxies Guacamole WebSocket to guacd.
|
- VNC: operator calls `/api/vnc/establish`, Engine creates a one-time token and proxies noVNC WebSocket to the agent VNC server.
|
||||||
- Notifications: operator or services call `/api/notifications/notify`, WebUI receives `borealis_notification` events.
|
- Notifications: operator or services call `/api/notifications/notify`, WebUI receives `borealis_notification` events.
|
||||||
|
|
||||||
## Directory Map (High Level)
|
## Directory Map (High Level)
|
||||||
@@ -50,7 +50,7 @@ None on this page. See [API Reference](api-reference.md).
|
|||||||
- Engine realtime: `Data/Engine/services/WebSocket/` (Socket.IO events: quick jobs, VPN shell, agent socket registry).
|
- Engine realtime: `Data/Engine/services/WebSocket/` (Socket.IO events: quick jobs, VPN shell, agent socket registry).
|
||||||
- WebUI hosting: `Data/Engine/services/WebUI/` (SPA static assets and 404 fallback).
|
- WebUI hosting: `Data/Engine/services/WebUI/` (SPA static assets and 404 fallback).
|
||||||
- VPN orchestration: `Data/Engine/services/VPN/` (WireGuard server and tunnel lifecycle).
|
- VPN orchestration: `Data/Engine/services/VPN/` (WireGuard server and tunnel lifecycle).
|
||||||
- Remote desktop proxy: `Data/Engine/services/RemoteDesktop/` (Guacamole WebSocket proxy).
|
- Remote desktop proxy: `Data/Engine/services/RemoteDesktop/` (VNC WebSocket proxy).
|
||||||
- Filters and targeting: `Data/Engine/services/filters/matcher.py` (used by scheduled jobs and filter counts).
|
- Filters and targeting: `Data/Engine/services/filters/matcher.py` (used by scheduled jobs and filter counts).
|
||||||
- Agent roles: `Data/Agent/Roles/` (script exec, screenshot, WireGuard tunnel, remote PowerShell, etc).
|
- Agent roles: `Data/Agent/Roles/` (script exec, screenshot, WireGuard tunnel, remote PowerShell, etc).
|
||||||
|
|
||||||
@@ -61,11 +61,10 @@ None on this page. See [API Reference](api-reference.md).
|
|||||||
3) Agent role executes and posts `quick_job_result` over Socket.IO.
|
3) Agent role executes and posts `quick_job_result` over Socket.IO.
|
||||||
4) Engine updates `activity_history` and emits `device_activity_changed`.
|
4) Engine updates `activity_history` and emits `device_activity_changed`.
|
||||||
- VPN shell:
|
- VPN shell:
|
||||||
1) UI calls `/api/tunnel/connect` to request tunnel material.
|
1) UI calls `/api/shell/establish` to ensure shell readiness.
|
||||||
2) Engine emits `vpn_tunnel_start` to agent socket.
|
2) Agent WireGuard role keeps the tunnel online; agent shell role listens on TCP 47002.
|
||||||
3) Agent WireGuard role starts tunnel; agent shell role listens on TCP 47002.
|
3) UI opens `vpn_shell_open` Socket.IO event; Engine bridges to TCP shell.
|
||||||
4) UI opens `vpn_shell_open` Socket.IO event; Engine bridges to TCP shell.
|
4) UI sends/receives `vpn_shell_send` and `vpn_shell_output` events.
|
||||||
5) UI sends/receives `vpn_shell_send` and `vpn_shell_output` events.
|
|
||||||
|
|
||||||
### Runtime boundaries
|
### Runtime boundaries
|
||||||
- Do not edit `Engine/` or `Agent/` directly. They are recreated on each launch.
|
- Do not edit `Engine/` or `Agent/` directly. They are recreated on each launch.
|
||||||
@@ -79,4 +78,4 @@ None on this page. See [API Reference](api-reference.md).
|
|||||||
### Interaction points to remember
|
### Interaction points to remember
|
||||||
- REST for inventory, enrollment, and admin actions.
|
- REST for inventory, enrollment, and admin actions.
|
||||||
- Socket.IO for realtime job results, VPN shell, and notifications.
|
- Socket.IO for realtime job results, VPN shell, and notifications.
|
||||||
- WireGuard for remote protocol transport (shell, RDP, future protocols).
|
- WireGuard for remote protocol transport (shell, VNC, future protocols).
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ Explain how Borealis tracks devices, ingests inventory, manages sites and filter
|
|||||||
|
|
||||||
## Codex Agent (Detailed)
|
## Codex Agent (Detailed)
|
||||||
### Key files and services
|
### Key files and services
|
||||||
- Device APIs: `Data/Engine/services/API/devices/` (management, approval, tunnel, rdp, routes).
|
- Device APIs: `Data/Engine/services/API/devices/` (management, approval, tunnel, vnc, routes).
|
||||||
- Filters: `Data/Engine/services/filters/matcher.py` and `Data/Engine/services/API/filters/management.py`.
|
- Filters: `Data/Engine/services/filters/matcher.py` and `Data/Engine/services/API/filters/management.py`.
|
||||||
- Enrollment approvals: `Data/Engine/services/API/devices/approval.py`.
|
- Enrollment approvals: `Data/Engine/services/API/devices/approval.py`.
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Describe the Borealis Engine runtime, its services, configuration, and operation
|
|||||||
- WebUI serving: `Data/Engine/services/WebUI/` (SPA static assets and 404 fallback).
|
- WebUI serving: `Data/Engine/services/WebUI/` (SPA static assets and 404 fallback).
|
||||||
- Realtime events: `Data/Engine/services/WebSocket/` (quick job results, VPN shell bridge).
|
- Realtime events: `Data/Engine/services/WebSocket/` (quick job results, VPN shell bridge).
|
||||||
- VPN orchestration: `Data/Engine/services/VPN/` (WireGuard server manager + tunnel service).
|
- VPN orchestration: `Data/Engine/services/VPN/` (WireGuard server manager + tunnel service).
|
||||||
- Remote desktop proxy: `Data/Engine/services/RemoteDesktop/` (Guacamole WebSocket bridge).
|
- Remote desktop proxy: `Data/Engine/services/RemoteDesktop/` (VNC WebSocket bridge).
|
||||||
- Assemblies: `Data/Engine/assembly_management/` and `Data/Engine/services/assemblies/`.
|
- Assemblies: `Data/Engine/assembly_management/` and `Data/Engine/services/assemblies/`.
|
||||||
|
|
||||||
## Runtime Paths
|
## Runtime Paths
|
||||||
@@ -41,7 +41,7 @@ Describe the Borealis Engine runtime, its services, configuration, and operation
|
|||||||
### EngineContext and lifecycle
|
### EngineContext and lifecycle
|
||||||
- `Data/Engine/server.py` builds an `EngineContext` that includes:
|
- `Data/Engine/server.py` builds an `EngineContext` that includes:
|
||||||
- TLS paths, WireGuard settings, scheduler, Socket.IO instance.
|
- TLS paths, WireGuard settings, scheduler, Socket.IO instance.
|
||||||
- RDP proxy settings (guacd host/port, ws host/port, session TTL).
|
- VNC proxy settings (VNC port, ws host/port, session TTL).
|
||||||
- The app factory wires in:
|
- The app factory wires in:
|
||||||
- API registration: `API.register_api(app, context)`
|
- API registration: `API.register_api(app, context)`
|
||||||
- WebUI static hosting: `WebUI.register_web_ui(app, context)`
|
- WebUI static hosting: `WebUI.register_web_ui(app, context)`
|
||||||
@@ -72,11 +72,12 @@ Describe the Borealis Engine runtime, its services, configuration, and operation
|
|||||||
- Dev UI uses Vite and still relies on Engine APIs for data.
|
- Dev UI uses Vite and still relies on Engine APIs for data.
|
||||||
- The SPA fallback in `Data/Engine/services/WebUI/__init__.py` prevents 404s on client routes.
|
- The SPA fallback in `Data/Engine/services/WebUI/__init__.py` prevents 404s on client routes.
|
||||||
|
|
||||||
### WireGuard and RDP wiring
|
### WireGuard and VNC wiring
|
||||||
- WireGuard server manager: `Data/Engine/services/VPN/wireguard_server.py`.
|
- WireGuard server manager: `Data/Engine/services/VPN/wireguard_server.py`.
|
||||||
- Tunnel orchestration: `Data/Engine/services/VPN/vpn_tunnel_service.py`.
|
- Tunnel orchestration: `Data/Engine/services/VPN/vpn_tunnel_service.py`.
|
||||||
- RDP proxy: `Data/Engine/services/RemoteDesktop/guacamole_proxy.py`.
|
- VNC proxy: `Data/Engine/services/RemoteDesktop/vnc_proxy.py`.
|
||||||
- API entrypoints: `/api/tunnel/*` and `/api/rdp/session`.
|
- API entrypoints: `/api/vnc/establish`, `/api/vnc/disconnect`, `/api/shell/establish`, `/api/shell/disconnect`.
|
||||||
|
- Persistent tunnels are established by agents via `POST /api/agent/vpn/ensure` and kept online.
|
||||||
|
|
||||||
### Assembly runtime
|
### Assembly runtime
|
||||||
- Assembly cache is initialized in `Data/Engine/assembly_management` and attached to `context.assembly_cache`.
|
- Assembly cache is initialized in `Data/Engine/assembly_management` and attached to `context.assembly_cache`.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ None. This index only links to other pages.
|
|||||||
- Read `getting-started.md` and `architecture-overview.md` to build the global model.
|
- Read `getting-started.md` and `architecture-overview.md` to build the global model.
|
||||||
- Use `engine-runtime.md` and `agent-runtime.md` for implementation-level details.
|
- Use `engine-runtime.md` and `agent-runtime.md` for implementation-level details.
|
||||||
- Use `ui-and-notifications.md` for MagicUI, AG Grid, and toast notification rules.
|
- Use `ui-and-notifications.md` for MagicUI, AG Grid, and toast notification rules.
|
||||||
- Use `vpn-and-remote-access.md` for WireGuard and remote shell/RDP details.
|
- Use `vpn-and-remote-access.md` for WireGuard and remote shell/VNC details.
|
||||||
- Use `security-and-trust.md` for enrollment, tokens, and code-signing behavior.
|
- Use `security-and-trust.md` for enrollment, tokens, and code-signing behavior.
|
||||||
|
|
||||||
### Where the truth lives in code
|
### Where the truth lives in code
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Explain the Borealis trust model, enrollment security, token handling, and code
|
|||||||
|
|
||||||
### WireGuard Agent to Engine Tunnels
|
### WireGuard Agent to Engine Tunnels
|
||||||
- Borealis started with a bespoke reverse tunnel stack (WebSocket framing + domain lanes); its handshake and security model did not scale, so the project moved to WireGuard as the Engine <-> Agent data pipeline for secure remote protocols and future remote desktop control.
|
- Borealis started with a bespoke reverse tunnel stack (WebSocket framing + domain lanes); its handshake and security model did not scale, so the project moved to WireGuard as the Engine <-> Agent data pipeline for secure remote protocols and future remote desktop control.
|
||||||
- On-demand, outbound-only: operators trigger a tunnel start, the agent dials the Engine (no inbound listeners), and the tunnel tears down on stop or idle.
|
- Persistent, outbound-only: agents ensure the tunnel at boot (no inbound listeners), and it remains online while the agent runs.
|
||||||
- Shared sessions: one live VPN tunnel per agent, reused across operators to avoid redundant connections.
|
- Shared sessions: one live VPN tunnel per agent, reused across operators to avoid redundant connections.
|
||||||
- Fast and robust transport: WireGuard provides encrypted UDP transport with lightweight handshakes that keep latency low and reconnects resilient.
|
- Fast and robust transport: WireGuard provides encrypted UDP transport with lightweight handshakes that keep latency low and reconnects resilient.
|
||||||
- Orchestration security: the Engine issues short-lived, Ed25519-signed tunnel tokens that the agent verifies before bringing the tunnel up.
|
- Orchestration security: the Engine issues short-lived, Ed25519-signed tunnel tokens that the agent verifies before bringing the tunnel up.
|
||||||
@@ -47,8 +47,8 @@ Explain the Borealis trust model, enrollment security, token handling, and code
|
|||||||
- Isolation by default: each agent gets a host-only /32; AllowedIPs are restricted to the agent /32 and the Engine /32; no LAN routes and no client-to-client traffic.
|
- Isolation by default: each agent gets a host-only /32; AllowedIPs are restricted to the agent /32 and the Engine /32; no LAN routes and no client-to-client traffic.
|
||||||
- Port-level controls: per-device allowlists plus Engine-applied firewall rules limit which protocols can traverse the tunnel.
|
- Port-level controls: per-device allowlists plus Engine-applied firewall rules limit which protocols can traverse the tunnel.
|
||||||
- Live PowerShell today: a VPN-only shell endpoint enables remote command execution with SYSTEM-level (`NT AUTHORITY\\SYSTEM`) access for deep diagnostics and remediation.
|
- Live PowerShell today: a VPN-only shell endpoint enables remote command execution with SYSTEM-level (`NT AUTHORITY\\SYSTEM`) access for deep diagnostics and remediation.
|
||||||
- Session lifecycle: 15-minute idle timeout with no grace period; session material includes a virtual IP plus allowed ports; teardown removes the tunnel and firewall rules.
|
- Session lifecycle: tunnels stay online with `PersistentKeepalive = 30`; session material includes a virtual IP plus allowed ports; role-level disconnects (shell/VNC) leave the tunnel intact.
|
||||||
- Future protocols: extend the same tunnel for SSH, WinRM, RDP, VNC, WebRTC streaming, and other remote management workflows by enabling ports per device.
|
- Future protocols: extend the same tunnel for SSH, WinRM, VNC, WebRTC streaming, and other remote management workflows by enabling ports per device.
|
||||||
|
|
||||||
## Enrollment and Identity
|
## Enrollment and Identity
|
||||||
- Enrollment uses install codes and operator approval.
|
- Enrollment uses install codes and operator approval.
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ This section captures the UI behavior requirements and troubleshooting context f
|
|||||||
#### Important references
|
#### Important references
|
||||||
- Toast API and payload rules are documented above.
|
- Toast API and payload rules are documented above.
|
||||||
- UI file: `Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx`.
|
- UI file: `Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx`.
|
||||||
- API status endpoint: `/api/tunnel/status` returns `agent_socket` when available.
|
- API establish endpoint: `/api/shell/establish` returns `agent_socket` when available.
|
||||||
- Socket error path: `agent_socket_missing`.
|
- Socket error path: `agent_socket_missing`.
|
||||||
|
|
||||||
#### Troubleshooting context
|
#### Troubleshooting context
|
||||||
|
|||||||
@@ -2,25 +2,28 @@
|
|||||||
[Back to Docs Index](index.md) | [Index (HTML)](index.html)
|
[Back to Docs Index](index.md) | [Index (HTML)](index.html)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Document Borealis remote access features: WireGuard reverse VPN tunnels, remote PowerShell, and RDP via Guacamole.
|
Document Borealis remote access features: WireGuard reverse VPN tunnels, remote PowerShell, and VNC via noVNC.
|
||||||
|
|
||||||
## WireGuard Reverse VPN (High Level)
|
## WireGuard Reverse VPN (High Level)
|
||||||
- Outbound-only: agents initiate tunnels to the Engine; no inbound listeners on devices.
|
- Outbound-only: agents initiate tunnels to the Engine; no inbound listeners on devices.
|
||||||
- Transport: WireGuard UDP 30000.
|
- Transport: WireGuard UDP 30000.
|
||||||
- One active tunnel per agent, shared across operators.
|
- One persistent tunnel per agent, established at agent boot and shared across operators.
|
||||||
- Host-only routing: each agent gets a /32; no client-to-client routes.
|
- Host-only routing: each agent gets a /32; no client-to-client routes.
|
||||||
- Idle timeout: 15 minutes without activity.
|
- Keepalive: `PersistentKeepalive = 30` seconds on the agent.
|
||||||
|
- No idle teardown while the agent service is running.
|
||||||
- Per-device allowlist: ports are restricted per device and enforced by Engine firewall rules.
|
- Per-device allowlist: ports are restricted per device and enforced by Engine firewall rules.
|
||||||
|
|
||||||
## Remote PowerShell
|
## Remote PowerShell
|
||||||
- Uses the WireGuard tunnel and a TCP shell server on the agent.
|
- Uses the WireGuard tunnel and a TCP shell server on the agent.
|
||||||
|
- UI establishes sessions via `/api/shell/establish` and disconnects via `/api/shell/disconnect`.
|
||||||
- Engine bridges UI Socket.IO events to the agent TCP shell.
|
- Engine bridges UI Socket.IO events to the agent TCP shell.
|
||||||
- Shell port default: 47002 (configurable).
|
- Shell port default: 47002 (configurable).
|
||||||
|
|
||||||
## RDP via Guacamole
|
## VNC via noVNC
|
||||||
- Engine issues one-time RDP session tokens via `/api/rdp/session`.
|
- Engine issues one-time VNC session tokens via `/api/vnc/establish`.
|
||||||
- WebUI connects to `ws(s)://<engine_host>:4823/guacamole`.
|
- WebUI connects to `ws(s)://<engine_host>:4823/vnc`.
|
||||||
- RDP allowed only if the device allowlist includes 3389.
|
- VNC allowed only if the device allowlist includes 5900.
|
||||||
|
- Agent runs UltraVNC as a Windows service; Borealis opens the VNC firewall rule on demand and closes it via `/api/vnc/disconnect`.
|
||||||
|
|
||||||
## Reverse Proxy Configuration
|
## Reverse Proxy Configuration
|
||||||
Traefik dynamic config (replace service URL with the actual Borealis Engine URL):
|
Traefik dynamic config (replace service URL with the actual Borealis Engine URL):
|
||||||
@@ -62,14 +65,16 @@ http:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
- `POST /api/tunnel/connect` (Token Authenticated) - start WireGuard tunnel.
|
- `POST /api/tunnel/connect` (Token Authenticated) - ensure WireGuard tunnel material for an agent.
|
||||||
- `GET /api/tunnel/status` (Token Authenticated) - tunnel status by agent.
|
- `GET /api/tunnel/status` (Token Authenticated) - tunnel status by agent.
|
||||||
- `GET /api/tunnel/connect/status` (Token Authenticated) - alias for status.
|
|
||||||
- `GET /api/tunnel/active` (Token Authenticated) - list active tunnels.
|
- `GET /api/tunnel/active` (Token Authenticated) - list active tunnels.
|
||||||
- `DELETE /api/tunnel/disconnect` (Token Authenticated) - stop tunnel.
|
- `POST /api/agent/vpn/ensure` (Device Authenticated) - agent-side persistent tunnel bootstrap.
|
||||||
- `GET /api/device/vpn_config/<agent_id>` (Token Authenticated) - read allowed ports.
|
- `GET /api/device/vpn_config/<agent_id>` (Token Authenticated) - read allowed ports.
|
||||||
- `PUT /api/device/vpn_config/<agent_id>` (Token Authenticated) - update allowed ports.
|
- `PUT /api/device/vpn_config/<agent_id>` (Token Authenticated) - update allowed ports.
|
||||||
- `POST /api/rdp/session` (Token Authenticated) - issue RDP session token.
|
- `POST /api/vnc/establish` (Token Authenticated) - establish VNC session token.
|
||||||
|
- `POST /api/vnc/disconnect` (Token Authenticated) - disconnect VNC session (closes firewall).
|
||||||
|
- `POST /api/shell/establish` (Token Authenticated) - establish remote shell session.
|
||||||
|
- `POST /api/shell/disconnect` (Token Authenticated) - disconnect remote shell session.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
- [Device Management](device-management.md)
|
- [Device Management](device-management.md)
|
||||||
@@ -83,12 +88,13 @@ http:
|
|||||||
- WireGuard server manager: `Data/Engine/services/VPN/wireguard_server.py`.
|
- WireGuard server manager: `Data/Engine/services/VPN/wireguard_server.py`.
|
||||||
- Tunnel API: `Data/Engine/services/API/devices/tunnel.py`.
|
- Tunnel API: `Data/Engine/services/API/devices/tunnel.py`.
|
||||||
- Shell bridge: `Data/Engine/services/WebSocket/vpn_shell.py`.
|
- Shell bridge: `Data/Engine/services/WebSocket/vpn_shell.py`.
|
||||||
- RDP session API: `Data/Engine/services/API/devices/rdp.py`.
|
- VNC session API: `Data/Engine/services/API/devices/vnc.py`.
|
||||||
- Guacamole proxy: `Data/Engine/services/RemoteDesktop/guacamole_proxy.py`.
|
- VNC proxy: `Data/Engine/services/RemoteDesktop/vnc_proxy.py`.
|
||||||
|
|
||||||
### Core Agent files
|
### Core Agent files
|
||||||
- WireGuard client role: `Data/Agent/Roles/role_WireGuardTunnel.py`.
|
- WireGuard client role: `Data/Agent/Roles/role_WireGuardTunnel.py`.
|
||||||
- Remote PowerShell role: `Data/Agent/Roles/role_RemotePowershell.py`.
|
- Remote PowerShell role: `Data/Agent/Roles/role_RemotePowershell.py`.
|
||||||
|
- VNC role: `Data/Agent/Roles/role_VNC.py`.
|
||||||
|
|
||||||
### Config paths
|
### Config paths
|
||||||
- Engine WireGuard config: `Engine/WireGuard/borealis-wg.conf`.
|
- Engine WireGuard config: `Engine/WireGuard/borealis-wg.conf`.
|
||||||
@@ -105,11 +111,11 @@ http:
|
|||||||
- `Borealis - WireGuard - Agent`
|
- `Borealis - WireGuard - Agent`
|
||||||
|
|
||||||
### Event flow (WireGuard tunnel)
|
### Event flow (WireGuard tunnel)
|
||||||
1) UI calls `/api/tunnel/connect`.
|
1) Agent calls `/api/agent/vpn/ensure` at boot (and periodically) to establish the persistent tunnel.
|
||||||
2) Engine creates a tunnel session and emits `vpn_tunnel_start`.
|
2) Engine creates or reuses the tunnel session and emits `vpn_tunnel_start`.
|
||||||
3) Agent verifies token signature and starts WireGuard client.
|
3) Agent verifies the token signature and starts the WireGuard client with `PersistentKeepalive = 30`.
|
||||||
4) Engine applies firewall allowlist rules for the agent /32.
|
4) Engine applies firewall allowlist rules for the agent /32.
|
||||||
5) Activity is recorded in `activity_history` as a VPN event.
|
5) UI sessions (shell/VNC) reuse the existing tunnel without teardown.
|
||||||
|
|
||||||
### Event flow (Remote PowerShell)
|
### Event flow (Remote PowerShell)
|
||||||
1) UI opens a shell and emits `vpn_shell_open`.
|
1) UI opens a shell and emits `vpn_shell_open`.
|
||||||
@@ -119,13 +125,16 @@ http:
|
|||||||
5) Agent returns stdout frames; Engine emits `vpn_shell_output`.
|
5) Agent returns stdout frames; Engine emits `vpn_shell_output`.
|
||||||
|
|
||||||
### Allowed ports and ACL rules
|
### Allowed ports and ACL rules
|
||||||
- Default allowlist (Windows): 3389, 5985, 5986, 5900, 3478, 47002.
|
- Default allowlist (Windows): 5985, 5986, 5900, 3478, 47002.
|
||||||
- Per-device overrides are stored in `device_vpn_config`.
|
- Per-device overrides are stored in `device_vpn_config`.
|
||||||
- Engine creates outbound firewall rules for each allowed port and protocol.
|
- Engine creates outbound firewall rules for each allowed port and protocol.
|
||||||
|
|
||||||
### Idle timeout behavior
|
### Persistent tunnel behavior
|
||||||
- The tunnel idle timer resets when activity is detected or when `bump_activity` is called.
|
- WireGuard sessions stay online while the agent service is running.
|
||||||
- Idle sessions are torn down and firewall rules are removed.
|
- There are no tunnel-disconnect endpoints; only role-level disconnects (shell/VNC) exist.
|
||||||
|
- The tunnel and per-device firewall rules remain in place while the agent runs.
|
||||||
|
- Keepalive is handled by WireGuard (`PersistentKeepalive = 30` seconds).
|
||||||
|
- The agent periodically calls `/api/agent/vpn/ensure` to heal the tunnel if it drops.
|
||||||
|
|
||||||
### Logs to inspect
|
### Logs to inspect
|
||||||
- Engine tunnel log: `Engine/Logs/VPN_Tunnel/tunnel.log`.
|
- Engine tunnel log: `Engine/Logs/VPN_Tunnel/tunnel.log`.
|
||||||
@@ -135,27 +144,28 @@ http:
|
|||||||
|
|
||||||
### Troubleshooting checklist
|
### Troubleshooting checklist
|
||||||
- Confirm WireGuard service is running (Engine and Agent).
|
- Confirm WireGuard service is running (Engine and Agent).
|
||||||
|
- Confirm the agent successfully calls `/api/agent/vpn/ensure` after boot.
|
||||||
- Confirm `/api/tunnel/status` returns `status=up` and `agent_socket=true`.
|
- Confirm `/api/tunnel/status` returns `status=up` and `agent_socket=true`.
|
||||||
- Verify `Agent/Borealis/Settings/WireGuard/Borealis.conf` during an active session.
|
- Verify `Agent/Borealis/Settings/WireGuard/Borealis.conf` during an active session.
|
||||||
- Test TCP shell reachability: `Test-NetConnection <agent_vpn_ip> -Port 47002`.
|
- Test TCP shell reachability: `Test-NetConnection <agent_vpn_ip> -Port 47002`.
|
||||||
|
|
||||||
### Known limitations
|
### Known limitations
|
||||||
- Legacy WebSocket tunnels are retired; only WireGuard is supported.
|
- Legacy WebSocket tunnels are retired; only WireGuard is supported.
|
||||||
- RDP requires guacd running and port 3389 allowed in VPN config.
|
- VNC requires UltraVNC running on the agent and port 5900 allowed in VPN config.
|
||||||
|
|
||||||
### Reverse VPN Tunnels (WireGuard) - Full Reference
|
### Reverse VPN Tunnels (WireGuard) - Full Reference
|
||||||
#### 1) High-level model
|
#### 1) High-level model
|
||||||
- Outbound-only: agents establish WireGuard tunnels to the Engine; no inbound access on devices.
|
- Outbound-only: agents establish WireGuard tunnels to the Engine; no inbound access on devices.
|
||||||
- Transport: WireGuard/UDP on port 30000.
|
- Transport: WireGuard/UDP on port 30000.
|
||||||
- Sessions: one live VPN tunnel per agent; multiple operators share it.
|
- Sessions: one persistent VPN tunnel per agent; multiple operators share it.
|
||||||
- Routing: host-only /32 per agent; AllowedIPs restricted to the agent /32 and engine /32; no client-to-client.
|
- Routing: host-only /32 per agent; AllowedIPs restricted to the agent /32 and engine /32; no client-to-client.
|
||||||
- Idle timeout: 15 minutes of no operator activity; no grace period.
|
- Keepalive: `PersistentKeepalive = 30` seconds; tunnels stay up while agents run.
|
||||||
- Keys: WireGuard server keys under `Engine/Certificates/VPN_Server`; client keys under `Agent/Borealis/Certificates/VPN_Client`.
|
- Keys: WireGuard server keys under `Engine/Certificates/VPN_Server`; client keys under `Agent/Borealis/Certificates/VPN_Client`.
|
||||||
|
|
||||||
#### 2) Engine components
|
#### 2) Engine components
|
||||||
- Orchestrator: `Data/Engine/services/VPN/vpn_tunnel_service.py`
|
- Orchestrator: `Data/Engine/services/VPN/vpn_tunnel_service.py`
|
||||||
- Allocates per-agent /32, issues short-lived orchestration tokens, enforces single-session.
|
- Allocates per-agent /32, issues short-lived orchestration tokens, enforces single-session.
|
||||||
- Starts/stops WireGuard listener, applies firewall rules, idles out on inactivity.
|
- Keeps the WireGuard listener online, applies firewall rules, and avoids idle teardown in persistent mode.
|
||||||
- Emits Socket.IO events: `vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`.
|
- Emits Socket.IO events: `vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`.
|
||||||
- WireGuard manager: `Data/Engine/services/VPN/wireguard_server.py`
|
- WireGuard manager: `Data/Engine/services/VPN/wireguard_server.py`
|
||||||
- Generates server keys, renders config, manages `wireguard.exe` tunnel service, applies ACL rules.
|
- Generates server keys, renders config, manages `wireguard.exe` tunnel service, applies ACL rules.
|
||||||
@@ -164,17 +174,20 @@ http:
|
|||||||
- Logging: `Engine/Logs/VPN_Tunnel/tunnel.log` plus Device Activity entries; shell I/O is in `Engine/Logs/VPN_Tunnel/remote_shell.log`.
|
- Logging: `Engine/Logs/VPN_Tunnel/tunnel.log` plus Device Activity entries; shell I/O is in `Engine/Logs/VPN_Tunnel/remote_shell.log`.
|
||||||
|
|
||||||
#### 3) API endpoints
|
#### 3) API endpoints
|
||||||
- `POST /api/tunnel/connect` -> issues session material (tunnel_id, token, virtual_ip, endpoint, allowed_ports, idle_seconds).
|
- `POST /api/tunnel/connect` -> issues or reuses session material (tunnel_id, token, virtual_ip, endpoint, allowed_ports).
|
||||||
- `GET /api/tunnel/status` -> returns up/down status for an agent.
|
- `GET /api/tunnel/status` -> returns up/down status for an agent.
|
||||||
- `GET /api/tunnel/connect/status` -> alias for status (used by UI before shell open).
|
|
||||||
- `GET /api/tunnel/active` -> lists active VPN tunnel sessions (tunnel_id, agent_id, virtual_ip, last_activity, etc.).
|
- `GET /api/tunnel/active` -> lists active VPN tunnel sessions (tunnel_id, agent_id, virtual_ip, last_activity, etc.).
|
||||||
- `DELETE /api/tunnel/disconnect` -> immediate teardown (agent and engine cleanup).
|
- `POST /api/agent/vpn/ensure` -> device-authenticated tunnel bootstrap for persistent mode.
|
||||||
|
- `POST /api/shell/establish` -> establish remote shell session.
|
||||||
|
- `POST /api/shell/disconnect` -> disconnect remote shell session.
|
||||||
|
- `POST /api/vnc/establish` -> establish VNC session token.
|
||||||
|
- `POST /api/vnc/disconnect` -> disconnect VNC session (closes firewall).
|
||||||
- `GET /api/device/vpn_config/<agent_id>` -> read per-agent allowed ports.
|
- `GET /api/device/vpn_config/<agent_id>` -> read per-agent allowed ports.
|
||||||
- `PUT /api/device/vpn_config/<agent_id>` -> update allowed ports.
|
- `PUT /api/device/vpn_config/<agent_id>` -> update allowed ports.
|
||||||
|
|
||||||
#### 4) Agent components
|
#### 4) Agent components
|
||||||
- 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 WireGuard client service, keeps the tunnel persistent.
|
||||||
- Shell server: `Data/Agent/Roles/role_RemotePowershell.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).
|
- 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).
|
- Logging: `Agent/Logs/VPN_Tunnel/tunnel.log` (tunnel lifecycle) and `Agent/Logs/VPN_Tunnel/remote_shell.log` (shell I/O).
|
||||||
@@ -221,8 +234,8 @@ This section consolidates the troubleshooting context and environment notes for
|
|||||||
- Configs must live inside the project root:
|
- Configs must live inside the project root:
|
||||||
- Agent: Agent\Borealis\Settings\WireGuard\Borealis.conf
|
- Agent: Agent\Borealis\Settings\WireGuard\Borealis.conf
|
||||||
- Engine: Engine\WireGuard\borealis-wg.conf
|
- Engine: Engine\WireGuard\borealis-wg.conf
|
||||||
- Agent brings up the WireGuard tunnel on vpn_tunnel_start, then remote shell/RDP/VNC/SSH flow through it.
|
- Agent ensures the WireGuard tunnel on boot via `/api/agent/vpn/ensure`, then remote shell/VNC/SSH flow through it.
|
||||||
- On stop/idle, the tunnel is torn down and firewall rules removed.
|
- No idle teardown; tunnels and firewall rules stay in place while the agent is running.
|
||||||
|
|
||||||
#### Recent changes (current repo state)
|
#### Recent changes (current repo state)
|
||||||
- Data/Agent/Roles/role_WireGuardTunnel.py
|
- Data/Agent/Roles/role_WireGuardTunnel.py
|
||||||
@@ -231,7 +244,8 @@ This section consolidates the troubleshooting context and environment notes for
|
|||||||
- Endpoint override: if Engine sends localhost, use host from server_url.txt and port from the token.
|
- Endpoint override: if Engine sends localhost, use host from server_url.txt and port from the token.
|
||||||
- Config path preference: Agent\Borealis\Settings\WireGuard.
|
- Config path preference: Agent\Borealis\Settings\WireGuard.
|
||||||
- Service display name set to "Borealis - WireGuard - Agent".
|
- Service display name set to "Borealis - WireGuard - Agent".
|
||||||
- Applies/removes the VPN shell firewall rule using the engine /32 from allowed_ips.
|
- Persistent tunnels with `PersistentKeepalive = 30`.
|
||||||
|
- Applies the VPN shell firewall rule using the engine /32 from allowed_ips.
|
||||||
- Data/Engine/services/VPN/wireguard_server.py
|
- Data/Engine/services/VPN/wireguard_server.py
|
||||||
- Engine config path: Engine\WireGuard\borealis-wg.conf (project root only).
|
- Engine config path: Engine\WireGuard\borealis-wg.conf (project root only).
|
||||||
- Removed invalid "SaveConfig = false" line (WireGuard rejected it).
|
- Removed invalid "SaveConfig = false" line (WireGuard rejected it).
|
||||||
@@ -283,5 +297,5 @@ Note: Data/Agent changes only apply after Borealis.ps1 re-stages the agent under
|
|||||||
#### Current blockers and next steps
|
#### Current blockers and next steps
|
||||||
1) Ensure the agent runtime is re-staged so `role_WireGuardTunnel.py` applies the shell firewall rule on tunnel start.
|
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.
|
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).
|
3) Confirm `Agent\Borealis\Settings\WireGuard\Borealis.conf` includes a [Peer] with endpoint/AllowedIPs while the agent is running.
|
||||||
4) Capture engine and agent tunnel/shell logs around a failed shell open attempt and re-check WireGuard service state if issues persist.
|
4) Capture engine and agent tunnel/shell logs around a failed shell open attempt and re-check WireGuard service state if issues persist.
|
||||||
|
|||||||
Reference in New Issue
Block a user