mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-17 02:05:48 -07:00
Reverse VPN Tunnel Deployment - Milestone: Engine VPN Server & ACLs (Windows)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
11
Data/Engine/services/VPN/__init__.py
Normal file
11
Data/Engine/services/VPN/__init__.py
Normal file
@@ -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
|
||||
|
||||
341
Data/Engine/services/VPN/wireguard_server.py
Normal file
341
Data/Engine/services/VPN/wireguard_server.py
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user