mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-17 02:05:48 -07:00
342 lines
13 KiB
Python
342 lines
13 KiB
Python
# ======================================================
|
|
# 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
|