Reverse VPN Tunnel Deployment - Milestone: Engine VPN Server & ACLs (Windows)

This commit is contained in:
2025-12-16 06:23:21 -07:00
parent 79793feb02
commit cd56317cce
5 changed files with 477 additions and 12 deletions

View File

@@ -84,6 +84,11 @@ DEFAULT_TUNNEL_PORT_RANGE = (30000, 40000)
DEFAULT_TUNNEL_IDLE_TIMEOUT_SECONDS = 3600 DEFAULT_TUNNEL_IDLE_TIMEOUT_SECONDS = 3600
DEFAULT_TUNNEL_GRACE_TIMEOUT_SECONDS = 3600 DEFAULT_TUNNEL_GRACE_TIMEOUT_SECONDS = 3600
DEFAULT_TUNNEL_HEARTBEAT_INTERVAL_SECONDS = 20 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: def _ensure_parent(path: Path) -> None:
@@ -212,6 +217,28 @@ def _parse_port_range(
return _clamp_pair(candidate) 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]]: 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 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 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_grace_timeout_seconds: int
reverse_tunnel_heartbeat_seconds: int reverse_tunnel_heartbeat_seconds: int
reverse_tunnel_log_file: str 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) raw: MutableMapping[str, Any] = field(default_factory=dict)
def to_flask_config(self) -> MutableMapping[str, Any]: 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)) _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( api_groups = _parse_api_groups(
runtime_config.get("API_GROUPS") or os.environ.get("BOREALIS_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_grace_timeout_seconds=tunnel_grace_timeout_seconds,
reverse_tunnel_heartbeat_seconds=tunnel_heartbeat_seconds, reverse_tunnel_heartbeat_seconds=tunnel_heartbeat_seconds,
reverse_tunnel_log_file=reverse_tunnel_log_file, 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, raw=runtime_config,
) )
return settings return settings

View File

@@ -23,6 +23,7 @@ import time
import ssl import ssl
from dataclasses import dataclass from dataclasses import dataclass
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
from typing import Any, Mapping, Optional, Sequence, Tuple 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 .config import EngineSettings, initialise_engine_logger, load_runtime_config
from .assembly_management import initialise_assembly_runtime from .assembly_management import initialise_assembly_runtime
from .services.VPN import WireGuardServerConfig, WireGuardServerManager
@dataclass @dataclass
@@ -124,6 +126,13 @@ class EngineContext:
reverse_tunnel_grace_timeout_seconds: int reverse_tunnel_grace_timeout_seconds: int
reverse_tunnel_heartbeat_seconds: int reverse_tunnel_heartbeat_seconds: int
reverse_tunnel_log_path: str 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 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_grace_timeout_seconds=settings.reverse_tunnel_grace_timeout_seconds,
reverse_tunnel_heartbeat_seconds=settings.reverse_tunnel_heartbeat_seconds, reverse_tunnel_heartbeat_seconds=settings.reverse_tunnel_heartbeat_seconds,
reverse_tunnel_log_path=settings.reverse_tunnel_log_file, 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, 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 = _build_engine_context(settings, logger)
context.socketio = socketio 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 = initialise_assembly_runtime(logger=logger, config=settings.as_dict())
assembly_cache.reload() assembly_cache.reload()
context.assembly_cache = assembly_cache 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) logger = initialise_engine_logger(settings)
context = _build_engine_context(settings, logger) 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 = initialise_assembly_runtime(logger=logger, config=settings.as_dict())
assembly_cache.reload() assembly_cache.reload()
context.assembly_cache = assembly_cache context.assembly_cache = assembly_cache

View 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

View 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

View File

@@ -46,22 +46,23 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- [x] WireGuard driver installed and visible. - [x] WireGuard driver installed and visible.
### 2) Engine VPN Server & ACLs — Milestone: Engine VPN Server & ACLs (Windows) ### 2) Engine VPN Server & ACLs — Milestone: Engine VPN Server & ACLs (Windows)
- 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: - Server config:
- Assign per-agent virtual IP (/32). Use AllowedIPs to restrict each peer to its /32. - [x] 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. - [x] 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] Do not push DNS or LAN routes; host-only reachability engine IP ↔ agent virtual /32.
- ACL layer: - ACL layer:
- Default allowlist per agent derived from OS (Windows: RDP 3389, WinRM 5985/5986, PS remoting ports; include VNC/WebRTC defaults as desired). - [x] 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] Allow operator overrides per agent; enforce at engine firewall layer. (rule plans produced; application wiring pending)
- Keys/Certs: - 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`. - [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`.
- 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). - [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). - Logging: server logs to `Engine/Logs/reverse_tunnel.log` (or renamed consistently). [x]
- Checkpoint tests: - Checkpoint tests:
- Engine starts WireGuard listener locally on 30000. - [x] Engine starts WireGuard listener locally on 30000.
- Only engine IP reachable; client-to-client blocked. - [x] Only engine IP reachable; client-to-client blocked.
- Peers without valid token/key are rejected. - [x] Peers without valid token/key are rejected.
### 3) Agent VPN Client & Lifecycle — Milestone: Agent VPN Client & Lifecycle (Windows) ### 3) Agent VPN Client & Lifecycle — Milestone: Agent VPN Client & Lifecycle (Windows)
- Agent config template: - Agent config template: