More VPN Tunnel Changes

This commit is contained in:
2026-01-11 20:53:09 -07:00
parent df14a1e26a
commit 3809fd25fb
13 changed files with 593 additions and 51 deletions

View File

@@ -24,9 +24,9 @@ ROLE_CONTEXTS = ["system"]
def _log_path() -> Path: def _log_path() -> Path:
root = Path(__file__).resolve().parents[2] / "Logs" root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
return root / "reverse_tunnel.log" return root / "remote_shell.log"
def _write_log(message: str) -> None: def _write_log(message: str) -> None:
@@ -61,8 +61,13 @@ class ShellSession:
self.address = address self.address = address
self.proc: Optional[subprocess.Popen] = None self.proc: Optional[subprocess.Popen] = None
self._stop = threading.Event() self._stop = threading.Event()
self.input_messages = 0
self.input_bytes = 0
self.output_lines = 0
self.output_bytes = 0
def start(self) -> None: def start(self) -> None:
_write_log(f"Shell session starting for {self.address[0]}:{self.address[1]}")
self.proc = subprocess.Popen( self.proc = subprocess.Popen(
["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"], ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
@@ -82,16 +87,27 @@ class ShellSession:
chunk = self.proc.stdout.readline() chunk = self.proc.stdout.readline()
if not chunk: if not chunk:
break break
self.output_lines += 1
self.output_bytes += len(chunk)
payload = json.dumps({"type": "stdout", "data": _b64encode(chunk)}) payload = json.dumps({"type": "stdout", "data": _b64encode(chunk)})
self.conn.sendall(payload.encode("utf-8") + b"\n") try:
except Exception: self.conn.sendall(payload.encode("utf-8") + b"\n")
pass except Exception as exc:
_write_log(f"Shell stdout send failed: {exc}")
break
_write_log(f"Shell stdout forwarded bytes={len(chunk)}")
except Exception as exc:
_write_log(f"Shell stdout error: {exc}")
def _writer_loop(self) -> None: def _writer_loop(self) -> None:
buffer = b"" buffer = b""
try: try:
while not self._stop.is_set(): while not self._stop.is_set():
data = self.conn.recv(4096) try:
data = self.conn.recv(4096)
except Exception as exc:
_write_log(f"Shell stdin recv error: {exc}")
break
if not data: if not data:
break break
buffer += data buffer += data
@@ -107,12 +123,17 @@ class ShellSession:
payload = msg.get("data") or "" payload = msg.get("data") or ""
if self.proc and self.proc.stdin: if self.proc and self.proc.stdin:
try: try:
self.proc.stdin.write(_b64decode(str(payload))) decoded = _b64decode(str(payload))
self.proc.stdin.write(decoded)
self.proc.stdin.flush() self.proc.stdin.flush()
self.input_messages += 1
self.input_bytes += len(decoded)
_write_log(f"Shell stdin received bytes={len(decoded)}")
except Exception: except Exception:
pass _write_log("Shell stdin write failed.")
if msg.get("type") == "close": if msg.get("type") == "close":
self._stop.set() self._stop.set()
_write_log("Shell close requested by engine.")
break break
finally: finally:
self.close() self.close()
@@ -128,6 +149,14 @@ class ShellSession:
self.proc.terminate() self.proc.terminate()
except Exception: except Exception:
pass pass
_write_log(
"Shell session closed inputs={0} input_bytes={1} output_lines={2} output_bytes={3}".format(
self.input_messages,
self.input_bytes,
self.output_lines,
self.output_bytes,
)
)
class ShellServer: class ShellServer:

View File

@@ -9,7 +9,7 @@
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, enforces idle teardown, and logs lifecycle events to
Agent/Logs/reverse_tunnel.log. It binds to Engine Socket.IO events Agent/Logs/VPN_Tunnel/tunnel.log. It binds to Engine Socket.IO events
(`vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`) to start/stop (`vpn_tunnel_start`, `vpn_tunnel_stop`, `vpn_tunnel_activity`) to start/stop
the client session with the issued config/token. the client session with the issued config/token.
""" """
@@ -44,9 +44,9 @@ ROLE_CONTEXTS = ["system"]
def _log_path() -> Path: def _log_path() -> Path:
root = Path(__file__).resolve().parents[2] / "Logs" root = Path(__file__).resolve().parents[2] / "Logs" / "VPN_Tunnel"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
return root / "reverse_tunnel.log" return root / "tunnel.log"
def _write_log(message: str) -> None: def _write_log(message: str) -> None:
@@ -303,11 +303,15 @@ class Role:
hooks = getattr(ctx, "hooks", {}) or {} hooks = getattr(ctx, "hooks", {}) or {}
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")
try:
self.client.stop_session(reason="agent_startup", ignore_missing=True)
except Exception:
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):
try: try:
self._log_hook(message, fname="reverse_tunnel.log") self._log_hook(message, fname="VPN_Tunnel/tunnel.log")
if error: if error:
self._log_hook(message, fname="agent.error.log") self._log_hook(message, fname="agent.error.log")
except Exception: except Exception:

View File

@@ -540,6 +540,9 @@ def _log_agent(message: str, fname: str = 'agent.log', *, scope: Optional[str] =
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
path = os.path.join(log_dir, fname) path = os.path.join(log_dir, fname)
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
_rotate_daily(path) _rotate_daily(path)
line = _format_agent_log_message(message, fname, scope) line = _format_agent_log_message(message, fname, scope)
with open(path, 'a', encoding='utf-8') as fh: with open(path, 'a', encoding='utf-8') as fh:

View File

@@ -77,7 +77,7 @@ LOG_ROOT = PROJECT_ROOT / "Engine" / "Logs"
LOG_FILE_PATH = LOG_ROOT / "engine.log" LOG_FILE_PATH = LOG_ROOT / "engine.log"
ERROR_LOG_FILE_PATH = LOG_ROOT / "error.log" ERROR_LOG_FILE_PATH = LOG_ROOT / "error.log"
API_LOG_FILE_PATH = LOG_ROOT / "api.log" API_LOG_FILE_PATH = LOG_ROOT / "api.log"
VPN_TUNNEL_LOG_FILE_PATH = LOG_ROOT / "reverse_tunnel.log" 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/24" DEFAULT_WIREGUARD_PEER_NETWORK = "10.255.0.0/24"

View File

@@ -129,6 +129,10 @@ def _make_service_logger(base: Path, logger: logging.Logger) -> Callable[[str, s
try: try:
base.mkdir(parents=True, exist_ok=True) base.mkdir(parents=True, exist_ok=True)
path = base / f"{service}.log" path = base / f"{service}.log"
try:
path.parent.mkdir(parents=True, exist_ok=True)
except Exception:
pass
_rotate_daily(path) _rotate_daily(path)
timestamp = time.strftime("%Y-%m-%d %H:%M:%S") timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
resolved_scope = _infer_server_scope(msg, scope) resolved_scope = _infer_server_scope(msg, scope)

View File

@@ -5,6 +5,7 @@
# 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.
# - DELETE /api/tunnel/disconnect (Token Authenticated) - Tears down VPN session for an agent. # - DELETE /api/tunnel/disconnect (Token Authenticated) - Tears down VPN session for an agent.
# ====================================================== # ======================================================
@@ -120,6 +121,21 @@ def _infer_endpoint_host(req) -> str:
def register_tunnel(app, adapters: "EngineServiceAdapters") -> None: def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
blueprint = Blueprint("vpn_tunnel", __name__) blueprint = Blueprint("vpn_tunnel", __name__)
logger = adapters.context.logger.getChild("vpn_tunnel.api") logger = adapters.context.logger.getChild("vpn_tunnel.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("VPN_Tunnel/tunnel", message, level=level)
except Exception:
logger.debug("vpn_tunnel 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()
@blueprint.route("/api/tunnel/connect", methods=["POST"]) @blueprint.route("/api/tunnel/connect", methods=["POST"])
def connect_tunnel(): def connect_tunnel():
@@ -139,15 +155,37 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
try: try:
tunnel_service = _get_tunnel_service(adapters) tunnel_service = _get_tunnel_service(adapters)
endpoint_host = _infer_endpoint_host(request) endpoint_host = _infer_endpoint_host(request)
_service_log_event(
"vpn_api_connect_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( payload = tunnel_service.connect(
agent_id=agent_id, agent_id=agent_id,
operator_id=operator_id, operator_id=operator_id,
endpoint_host=endpoint_host, endpoint_host=endpoint_host,
) )
except Exception as exc: except Exception as exc:
_service_log_event(
"vpn_api_connect_failed agent_id={0} operator={1} error={2}".format(
agent_id,
operator_id or "-",
str(exc),
),
level="ERROR",
)
logger.warning("vpn connect failed for agent_id=%s: %s", agent_id, exc) logger.warning("vpn connect failed for agent_id=%s: %s", agent_id, exc)
return jsonify({"error": "connect_failed", "detail": str(exc)}), 500 return jsonify({"error": "connect_failed", "detail": str(exc)}), 500
_service_log_event(
"vpn_api_connect_response agent_id={0} tunnel_id={1} status=ok".format(
payload.get("agent_id", agent_id),
payload.get("tunnel_id", "-"),
)
)
return jsonify(payload), 200 return jsonify(payload), 200
@blueprint.route("/api/tunnel/status", methods=["GET"]) @blueprint.route("/api/tunnel/status", methods=["GET"])
@@ -163,18 +201,51 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
tunnel_service = _get_tunnel_service(adapters) tunnel_service = _get_tunnel_service(adapters)
payload = tunnel_service.status(agent_id) payload = tunnel_service.status(agent_id)
bump = _normalize_text(request.args.get("bump") or "")
_service_log_event(
"vpn_api_status_request agent_id={0} bump={1} remote={2}".format(
agent_id,
"true" if bump else "false",
_request_remote() or "-",
)
)
if not payload: if not payload:
_service_log_event(
"vpn_api_status_response agent_id={0} status=down".format(agent_id)
)
return jsonify({"status": "down", "agent_id": agent_id}), 200 return jsonify({"status": "down", "agent_id": agent_id}), 200
payload["status"] = "up" payload["status"] = "up"
bump = _normalize_text(request.args.get("bump") or "")
if bump: if bump:
tunnel_service.bump_activity(agent_id) tunnel_service.bump_activity(agent_id)
_service_log_event(
"vpn_api_status_response agent_id={0} status=up tunnel_id={1}".format(
agent_id,
payload.get("tunnel_id", "-"),
)
)
return jsonify(payload), 200 return jsonify(payload), 200
@blueprint.route("/api/tunnel/connect/status", methods=["GET"]) @blueprint.route("/api/tunnel/connect/status", methods=["GET"])
def tunnel_connect_status(): def tunnel_connect_status():
return tunnel_status() return tunnel_status()
@blueprint.route("/api/tunnel/active", methods=["GET"])
def tunnel_active():
requirement = _require_login(app)
if requirement:
payload, status = requirement
return jsonify(payload), status
tunnel_service = _get_tunnel_service(adapters)
sessions = list(tunnel_service.list_sessions())
_service_log_event(
"vpn_api_active_response count={0} remote={1}".format(
len(sessions),
_request_remote() or "-",
)
)
return jsonify({"count": len(sessions), "tunnels": sessions}), 200
@blueprint.route("/api/tunnel/disconnect", methods=["DELETE"]) @blueprint.route("/api/tunnel/disconnect", methods=["DELETE"])
def disconnect_tunnel(): def disconnect_tunnel():
requirement = _require_login(app) requirement = _require_login(app)
@@ -188,6 +259,15 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
reason = _normalize_text(body.get("reason") or "operator_stop") reason = _normalize_text(body.get("reason") or "operator_stop")
tunnel_service = _get_tunnel_service(adapters) 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 stopped = False
if tunnel_id: if tunnel_id:
stopped = tunnel_service.disconnect_by_tunnel(tunnel_id, reason=reason) stopped = tunnel_service.disconnect_by_tunnel(tunnel_id, reason=reason)
@@ -197,8 +277,21 @@ def register_tunnel(app, adapters: "EngineServiceAdapters") -> None:
return jsonify({"error": "agent_id_required"}), 400 return jsonify({"error": "agent_id_required"}), 400
if not stopped: 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 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 return jsonify({"status": "stopped", "reason": reason}), 200
app.register_blueprint(blueprint) app.register_blueprint(blueprint)

View File

@@ -15,6 +15,7 @@ import json
import threading import threading
import time import time
import uuid import uuid
from datetime import datetime, timezone
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
@@ -66,6 +67,7 @@ class VpnTunnelService:
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._idle_thread = threading.Thread(target=self._idle_loop, daemon=True) self._idle_thread = threading.Thread(target=self._idle_loop, daemon=True)
self._idle_thread.start() self._idle_thread.start()
@@ -79,6 +81,15 @@ class VpnTunnelService:
if session.last_activity + self.idle_seconds <= now: if session.last_activity + self.idle_seconds <= now:
expired.append(session) expired.append(session)
for session in expired: for session in expired:
self._service_log_event(
"vpn_tunnel_idle_timeout agent_id={0} tunnel_id={1} last_activity={2} last_activity_iso={3} idle_seconds={4}".format(
session.agent_id,
session.tunnel_id,
int(session.last_activity),
self._ts_to_iso(session.last_activity),
self.idle_seconds,
)
)
self.disconnect(session.agent_id, reason="idle_timeout") self.disconnect(session.agent_id, reason="idle_timeout")
def _allocate_virtual_ip(self, agent_id: str) -> str: def _allocate_virtual_ip(self, agent_id: str) -> str:
@@ -200,13 +211,27 @@ class VpnTunnelService:
return f"[{host}]" return f"[{host}]"
return host return host
def _ts_to_iso(self, ts: float) -> str:
try:
return datetime.fromtimestamp(ts, timezone.utc).isoformat()
except Exception:
return ""
def _service_log_event(self, message: str, *, level: str = "INFO") -> None: def _service_log_event(self, message: str, *, level: str = "INFO") -> None:
if not callable(self.service_log): if not callable(self.service_log):
return return
try: try:
self.service_log("reverse_tunnel", message, level=level) self.service_log("VPN_Tunnel/tunnel", message, level=level)
except Exception: except Exception:
self.logger.debug("Failed to write reverse_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:
try:
self.wg.stop_listener(ignore_missing=True)
self._service_log_event("vpn_listener_cleanup reason=startup")
except Exception:
self.logger.debug("Failed to clean up WireGuard listener on startup.", exc_info=True)
self._service_log_event("vpn_listener_cleanup_failed reason=startup", level="WARNING")
def _refresh_listener(self) -> None: def _refresh_listener(self) -> None:
peers: List[Mapping[str, object]] = [] peers: List[Mapping[str, object]] = []
@@ -220,8 +245,11 @@ class VpnTunnelService:
peer["public_key"] = session.client_public_key peer["public_key"] = session.client_public_key
peers.append(peer) peers.append(peer)
if not peers: if not peers:
self._service_log_event("vpn_listener_stop reason=no_peers")
self.wg.stop_listener() self.wg.stop_listener()
return return
agent_list = ",".join(str(peer.get("agent_id", "")) for peer in peers if peer.get("agent_id"))
self._service_log_event("vpn_listener_start peers={0} agents={1}".format(len(peers), agent_list))
self.wg.start_listener(peers) self.wg.start_listener(peers)
def connect( def connect(
@@ -233,6 +261,14 @@ class VpnTunnelService:
) -> Mapping[str, Any]: ) -> Mapping[str, Any]:
now = time.time() now = time.time()
normalized_host = self._normalize_endpoint_host(endpoint_host) normalized_host = self._normalize_endpoint_host(endpoint_host)
operator_text = operator_id or "-"
self._service_log_event(
"vpn_tunnel_connect_request agent_id={0} operator={1} endpoint_host={2}".format(
agent_id or "-",
operator_text,
normalized_host or "-",
)
)
with self._lock: with self._lock:
existing = self._sessions_by_agent.get(agent_id) existing = self._sessions_by_agent.get(agent_id)
if existing: if existing:
@@ -241,7 +277,18 @@ class VpnTunnelService:
if normalized_host and not existing.endpoint_host: if normalized_host and not existing.endpoint_host:
existing.endpoint_host = normalized_host existing.endpoint_host = normalized_host
existing.last_activity = now existing.last_activity = now
previous_expiry = existing.expires_at
self._ensure_token(existing, now=now) self._ensure_token(existing, now=now)
refreshed = existing.expires_at != previous_expiry
operator_list = ",".join(sorted(filter(None, existing.operator_ids))) or "-"
self._service_log_event(
"vpn_tunnel_session_reuse agent_id={0} tunnel_id={1} operators={2} token_refreshed={3}".format(
existing.agent_id,
existing.tunnel_id,
operator_list,
str(refreshed).lower(),
)
)
return self._session_payload(existing) return self._session_payload(existing)
tunnel_id = uuid.uuid4().hex tunnel_id = uuid.uuid4().hex
@@ -250,6 +297,7 @@ class VpnTunnelService:
client_private, client_public = self._generate_client_keys() client_private, client_public = self._generate_client_keys()
token = self._issue_token(agent_id, tunnel_id, now + 300) token = self._issue_token(agent_id, tunnel_id, now + 300)
self.wg.require_orchestration_token(token) self.wg.require_orchestration_token(token)
token_signed = "signature" in token
session = VpnSession( session = VpnSession(
tunnel_id=tunnel_id, tunnel_id=tunnel_id,
@@ -270,6 +318,16 @@ class VpnTunnelService:
self._sessions_by_tunnel[tunnel_id] = session self._sessions_by_tunnel[tunnel_id] = session
try: try:
self._service_log_event(
"vpn_tunnel_session_create agent_id={0} tunnel_id={1} virtual_ip={2} allowed_ports={3} token_signed={4} token_expires={5}".format(
session.agent_id,
session.tunnel_id,
session.virtual_ip,
",".join(str(p) for p in allowed_ports),
str(bool(token_signed)).lower(),
int(session.expires_at),
)
)
self._refresh_listener() self._refresh_listener()
peer = self.wg.build_peer_profile( peer = self.wg.build_peer_profile(
@@ -279,7 +337,18 @@ class VpnTunnelService:
) )
rule_names = self.wg.apply_firewall_rules(peer) rule_names = self.wg.apply_firewall_rules(peer)
session.firewall_rules = rule_names session.firewall_rules = rule_names
self._service_log_event(
"vpn_tunnel_firewall_applied agent_id={0} tunnel_id={1} rules={2}".format(
session.agent_id,
session.tunnel_id,
len(rule_names),
)
)
except Exception: except Exception:
self._service_log_event(
"vpn_tunnel_connect_failed agent_id={0} tunnel_id={1}".format(agent_id, tunnel_id),
level="ERROR",
)
with self._lock: with self._lock:
self._sessions_by_agent.pop(agent_id, None) self._sessions_by_agent.pop(agent_id, None)
self._sessions_by_tunnel.pop(tunnel_id, None) self._sessions_by_tunnel.pop(tunnel_id, None)
@@ -312,6 +381,11 @@ class VpnTunnelService:
return None return None
return self._session_payload(session, include_token=False) return self._session_payload(session, include_token=False)
def list_sessions(self) -> List[Mapping[str, Any]]:
with self._lock:
sessions = sorted(self._sessions_by_agent.values(), key=lambda s: s.agent_id)
return [self._session_summary(session) for session in sessions]
def session_payload(self, agent_id: str, *, include_token: bool = True) -> Optional[Mapping[str, Any]]: def session_payload(self, agent_id: str, *, include_token: bool = True) -> Optional[Mapping[str, Any]]:
with self._lock: with self._lock:
session = self._sessions_by_agent.get(agent_id) session = self._sessions_by_agent.get(agent_id)
@@ -324,7 +398,14 @@ class VpnTunnelService:
def request_agent_start(self, agent_id: str) -> Optional[Mapping[str, Any]]: def request_agent_start(self, agent_id: str) -> Optional[Mapping[str, Any]]:
payload = self.session_payload(agent_id, include_token=True) payload = self.session_payload(agent_id, include_token=True)
if not payload: if not payload:
self._service_log_event("vpn_tunnel_agent_start_missing agent_id={0}".format(agent_id or "-"))
return None return None
self._service_log_event(
"vpn_tunnel_agent_start_emit agent_id={0} tunnel_id={1}".format(
payload.get("agent_id", "-"),
payload.get("tunnel_id", "-"),
)
)
self._emit_start(payload) self._emit_start(payload)
return payload return payload
@@ -333,7 +414,18 @@ class VpnTunnelService:
session = self._sessions_by_agent.get(agent_id) session = self._sessions_by_agent.get(agent_id)
if not session: if not session:
return return
session.last_activity = time.time() now = time.time()
previous = session.last_activity
session.last_activity = now
idle_for = now - previous
if idle_for >= 60:
self._service_log_event(
"vpn_tunnel_activity_bump agent_id={0} tunnel_id={1} idle_for={2}".format(
session.agent_id,
session.tunnel_id,
int(idle_for),
)
)
try: try:
if self.socketio: if self.socketio:
self.socketio.emit("vpn_tunnel_activity", {"agent_id": agent_id}, namespace="/") self.socketio.emit("vpn_tunnel_activity", {"agent_id": agent_id}, namespace="/")
@@ -344,6 +436,9 @@ class VpnTunnelService:
with self._lock: with self._lock:
session = self._sessions_by_agent.pop(agent_id, None) session = self._sessions_by_agent.pop(agent_id, None)
if not session: if not session:
self._service_log_event(
"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) self._sessions_by_tunnel.pop(session.tunnel_id, None)
@@ -370,6 +465,9 @@ class VpnTunnelService:
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:
self._service_log_event(
"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)
@@ -383,13 +481,27 @@ class VpnTunnelService:
if agent_id and callable(emit_agent): if agent_id and callable(emit_agent):
try: try:
if emit_agent(agent_id, "vpn_tunnel_start", payload): if emit_agent(agent_id, "vpn_tunnel_start", payload):
self._service_log_event(
"vpn_tunnel_start_emit agent_id={0} transport=direct".format(agent_id or "-")
)
return return
except Exception: except Exception:
self.logger.debug("emit_agent_event failed for vpn_tunnel_start", exc_info=True) self.logger.debug("emit_agent_event failed for vpn_tunnel_start", exc_info=True)
self._service_log_event(
"vpn_tunnel_start_emit_failed agent_id={0} transport=direct".format(agent_id or "-"),
level="WARNING",
)
try: try:
self._service_log_event(
"vpn_tunnel_start_emit agent_id={0} transport=broadcast".format(agent_id or "-")
)
self.socketio.emit("vpn_tunnel_start", payload, namespace="/") self.socketio.emit("vpn_tunnel_start", payload, namespace="/")
except Exception: except Exception:
self.logger.debug("vpn_tunnel_start emit failed", exc_info=True) self.logger.debug("vpn_tunnel_start emit failed", exc_info=True)
self._service_log_event(
"vpn_tunnel_start_emit_failed agent_id={0} transport=broadcast".format(agent_id or "-"),
level="WARNING",
)
def _emit_stop(self, session: VpnSession, reason: str) -> None: def _emit_stop(self, session: VpnSession, reason: str) -> None:
if not self.socketio: if not self.socketio:
@@ -402,10 +514,29 @@ class VpnTunnelService:
"vpn_tunnel_stop", "vpn_tunnel_stop",
{"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason}, {"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason},
): ):
self._service_log_event(
"vpn_tunnel_stop_emit agent_id={0} tunnel_id={1} transport=direct".format(
session.agent_id,
session.tunnel_id,
)
)
return return
except Exception: except Exception:
self.logger.debug("emit_agent_event failed for vpn_tunnel_stop", exc_info=True) self.logger.debug("emit_agent_event failed for vpn_tunnel_stop", exc_info=True)
self._service_log_event(
"vpn_tunnel_stop_emit_failed agent_id={0} tunnel_id={1} transport=direct".format(
session.agent_id,
session.tunnel_id,
),
level="WARNING",
)
try: try:
self._service_log_event(
"vpn_tunnel_stop_emit agent_id={0} tunnel_id={1} transport=broadcast".format(
session.agent_id,
session.tunnel_id,
)
)
self.socketio.emit( self.socketio.emit(
"vpn_tunnel_stop", "vpn_tunnel_stop",
{"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason}, {"agent_id": session.agent_id, "tunnel_id": session.tunnel_id, "reason": reason},
@@ -413,6 +544,13 @@ class VpnTunnelService:
) )
except Exception: except Exception:
self.logger.debug("vpn_tunnel_stop emit failed", exc_info=True) self.logger.debug("vpn_tunnel_stop emit failed", exc_info=True)
self._service_log_event(
"vpn_tunnel_stop_emit_failed agent_id={0} tunnel_id={1} transport=broadcast".format(
session.agent_id,
session.tunnel_id,
),
level="WARNING",
)
def _log_device_activity(self, session: VpnSession, *, event: str, reason: Optional[str] = None) -> None: def _log_device_activity(self, session: VpnSession, *, event: str, reason: Optional[str] = None) -> None:
if self.db_conn_factory is None: if self.db_conn_factory is None:
@@ -573,3 +711,24 @@ class VpnTunnelService:
if include_token: if include_token:
payload["token"] = session.token payload["token"] = session.token
return payload return payload
def _session_summary(self, session: VpnSession) -> Mapping[str, Any]:
endpoint_host = session.endpoint_host or str(self._engine_ip.ip)
endpoint_host = self._format_endpoint_host(endpoint_host)
return {
"tunnel_id": session.tunnel_id,
"agent_id": session.agent_id,
"virtual_ip": session.virtual_ip,
"engine_virtual_ip": str(self._engine_ip.ip),
"endpoint": f"{endpoint_host}:{self.context.wireguard_port}",
"allowed_ports": list(session.allowed_ports),
"connected_operators": len([o for o in session.operator_ids if o]),
"created_at": int(session.created_at),
"created_at_iso": self._ts_to_iso(session.created_at),
"last_activity": int(session.last_activity),
"last_activity_iso": self._ts_to_iso(session.last_activity),
"expires_at": int(session.expires_at),
"expires_at_iso": self._ts_to_iso(session.expires_at),
"idle_seconds": self.idle_seconds,
"status": "up",
}

View File

@@ -336,12 +336,16 @@ 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)
def stop_listener(self) -> None: def stop_listener(self, *, ignore_missing: bool = False) -> None:
"""Stop and remove the WireGuard tunnel service.""" """Stop and remove the WireGuard tunnel service."""
args = [self._wireguard_exe, "/uninstalltunnelservice", self._service_name] args = [self._wireguard_exe, "/uninstalltunnelservice", self._service_name]
code, out, err = self._run_command(args) code, out, err = self._run_command(args)
if code != 0: 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")
return
self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err) self.logger.warning("Failed to uninstall WireGuard tunnel service code=%s err=%s", code, err)
else: else:
self.logger.info("WireGuard tunnel service removed") self.logger.info("WireGuard tunnel service removed")

View File

@@ -103,7 +103,7 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
adapters = EngineRealtimeAdapters(context) adapters = EngineRealtimeAdapters(context)
logger = context.logger.getChild("realtime.quick_jobs") logger = context.logger.getChild("realtime.quick_jobs")
agent_logger = context.logger.getChild("realtime.agents") agent_logger = context.logger.getChild("realtime.agents")
shell_bridge = VpnShellBridge(socket_server, context) shell_bridge = VpnShellBridge(socket_server, context, adapters.service_log)
agent_registry = AgentSocketRegistry(socket_server, agent_logger) agent_registry = AgentSocketRegistry(socket_server, agent_logger)
def _emit_agent_event(agent_id: str, event: str, payload: Any) -> bool: def _emit_agent_event(agent_id: str, event: str, payload: Any) -> bool:
@@ -148,6 +148,24 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
setattr(context, "vpn_tunnel_service", service) setattr(context, "vpn_tunnel_service", service)
return service return service
def _tunnel_log(message: str, *, level: str = "INFO") -> None:
try:
adapters.service_log("VPN_Tunnel/tunnel", message, level=level)
except Exception:
agent_logger.debug("vpn_tunnel service log write failed", exc_info=True)
def _shell_log(message: str, *, level: str = "INFO") -> None:
try:
adapters.service_log("VPN_Tunnel/remote_shell", message, level=level)
except Exception:
agent_logger.debug("vpn_shell service log write failed", exc_info=True)
def _remote_addr() -> str:
forwarded = (request.headers.get("X-Forwarded-For") or "").strip()
if forwarded:
return forwarded.split(",")[0].strip()
return (request.remote_addr or "").strip()
@socket_server.on("quick_job_result") @socket_server.on("quick_job_result")
def _handle_quick_job_result(data: Any) -> None: def _handle_quick_job_result(data: Any) -> None:
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -317,18 +335,59 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
elif isinstance(data, str): elif isinstance(data, str):
agent_id = data.strip() agent_id = data.strip()
if not agent_id: if not agent_id:
_shell_log(
"vpn_shell_open_missing sid={0} remote={1}".format(
request.sid,
_remote_addr() or "-",
),
level="WARNING",
)
return {"error": "agent_id_required"} return {"error": "agent_id_required"}
_shell_log(
"vpn_shell_open_request agent_id={0} sid={1} remote={2}".format(
agent_id,
request.sid,
_remote_addr() or "-",
)
)
service = _get_tunnel_service() service = _get_tunnel_service()
if service is None: if service is None:
_shell_log(
"vpn_shell_open_failed agent_id={0} sid={1} reason=vpn_service_unavailable".format(
agent_id,
request.sid,
),
level="WARNING",
)
return {"error": "vpn_service_unavailable"} return {"error": "vpn_service_unavailable"}
if not service.status(agent_id): if not service.status(agent_id):
_shell_log(
"vpn_shell_open_failed agent_id={0} sid={1} reason=tunnel_down".format(
agent_id,
request.sid,
),
level="WARNING",
)
return {"error": "tunnel_down"} return {"error": "tunnel_down"}
session = shell_bridge.open_session(request.sid, agent_id) session = shell_bridge.open_session(request.sid, agent_id)
if session is None: if session is None:
_shell_log(
"vpn_shell_open_failed agent_id={0} sid={1} reason=shell_connect_failed".format(
agent_id,
request.sid,
),
level="WARNING",
)
return {"error": "shell_connect_failed"} return {"error": "shell_connect_failed"}
service.bump_activity(agent_id) service.bump_activity(agent_id)
_shell_log(
"vpn_shell_open_success agent_id={0} sid={1}".format(
agent_id,
request.sid,
)
)
return {"status": "ok"} return {"status": "ok"}
@socket_server.on("connect_agent") @socket_server.on("connect_agent")
@@ -341,16 +400,38 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
elif isinstance(data, str): elif isinstance(data, str):
agent_id = data.strip() agent_id = data.strip()
if not agent_id: if not agent_id:
_tunnel_log(
"vpn_agent_socket_missing sid={0} remote={1}".format(
request.sid,
_remote_addr() or "-",
),
level="WARNING",
)
return {"error": "agent_id_required"} return {"error": "agent_id_required"}
agent_registry.register(agent_id, request.sid) agent_registry.register(agent_id, request.sid)
agent_logger.info("Agent socket registered agent_id=%s service_mode=%s sid=%s", agent_id, service_mode, request.sid) agent_logger.info("Agent socket registered agent_id=%s service_mode=%s sid=%s", agent_id, service_mode, request.sid)
_tunnel_log(
"vpn_agent_socket_register agent_id={0} service_mode={1} sid={2} remote={3}".format(
agent_id,
service_mode or "-",
request.sid,
_remote_addr() or "-",
)
)
service = _get_tunnel_service() service = _get_tunnel_service()
if service: if service:
payload = service.session_payload(agent_id, include_token=True) payload = service.session_payload(agent_id, include_token=True)
if payload: if payload:
agent_registry.emit(agent_id, "vpn_tunnel_start", payload) if agent_registry.emit(agent_id, "vpn_tunnel_start", payload):
_tunnel_log(
"vpn_agent_socket_emit_start agent_id={0} tunnel_id={1} sid={2}".format(
agent_id,
payload.get("tunnel_id", "-"),
request.sid,
)
)
return {"status": "ok"} return {"status": "ok"}
@@ -363,11 +444,28 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
payload = data payload = data
if payload is None: if payload is None:
return {"error": "payload_required"} return {"error": "payload_required"}
try:
payload_len = len(str(payload))
except Exception:
payload_len = 0
_shell_log(
"vpn_shell_send_request sid={0} bytes={1} remote={2}".format(
request.sid,
payload_len,
_remote_addr() or "-",
)
)
shell_bridge.send(request.sid, str(payload)) shell_bridge.send(request.sid, str(payload))
return {"status": "ok"} return {"status": "ok"}
@socket_server.on("vpn_shell_close") @socket_server.on("vpn_shell_close")
def _vpn_shell_close(data: Any = None) -> Dict[str, Any]: def _vpn_shell_close(data: Any = None) -> Dict[str, Any]:
_shell_log(
"vpn_shell_close_request sid={0} remote={1}".format(
request.sid,
_remote_addr() or "-",
)
)
shell_bridge.close(request.sid) shell_bridge.close(request.sid)
return {"status": "ok"} return {"status": "ok"}
@@ -376,4 +474,18 @@ def register_realtime(socket_server: SocketIO, context: EngineContext) -> None:
agent_id = agent_registry.unregister(request.sid) agent_id = agent_registry.unregister(request.sid)
if agent_id: if agent_id:
agent_logger.info("Agent socket disconnected agent_id=%s sid=%s", agent_id, request.sid) agent_logger.info("Agent socket disconnected agent_id=%s sid=%s", agent_id, request.sid)
_tunnel_log(
"vpn_agent_socket_disconnect agent_id={0} sid={1}".format(
agent_id,
request.sid,
)
)
else:
_shell_log(
"vpn_shell_client_disconnect sid={0} remote={1}".format(
request.sid,
_remote_addr() or "-",
),
level="WARNING",
)
shell_bridge.close(request.sid) shell_bridge.close(request.sid)

View File

@@ -15,7 +15,7 @@ import socket
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, Optional from typing import Any, Callable, Dict, Optional
def _b64encode(data: bytes) -> str: def _b64encode(data: bytes) -> str:
@@ -32,6 +32,11 @@ class ShellSession:
agent_id: str agent_id: str
socketio: Any socketio: Any
tcp: socket.socket tcp: socket.socket
service_log: Optional[Callable[[str, str, Optional[str]], None]] = None
output_lines: int = 0
output_bytes: int = 0
input_messages: int = 0
input_bytes: int = 0
_reader: Optional[threading.Thread] = None _reader: Optional[threading.Thread] = None
def start_reader(self) -> None: def start_reader(self) -> None:
@@ -39,15 +44,31 @@ class ShellSession:
t.start() t.start()
self._reader = t self._reader = t
def _service_log_event(self, message: str, *, level: str = "INFO") -> None:
if not callable(self.service_log):
return
try:
self.service_log("VPN_Tunnel/remote_shell", message, level=level)
except Exception:
pass
def _read_loop(self) -> None: def _read_loop(self) -> None:
buffer = b"" buffer = b""
reason = "remote_closed"
error_detail = ""
try: try:
while True: while True:
try: try:
data = self.tcp.recv(4096) data = self.tcp.recv(4096)
except (socket.timeout, TimeoutError): except (socket.timeout, TimeoutError):
# No data ready; keep the session alive.
continue
except Exception as exc:
reason = "read_error"
error_detail = f"{type(exc).__name__}:{exc}"
break break
if not data: if not data:
reason = "remote_closed"
break break
buffer += data buffer += data
while b"\n" in buffer: while b"\n" in buffer:
@@ -64,8 +85,37 @@ class ShellSession:
decoded = _b64decode(str(payload)).decode("utf-8", errors="replace") decoded = _b64decode(str(payload)).decode("utf-8", errors="replace")
except Exception: except Exception:
decoded = "" decoded = ""
self.output_lines += 1
self.output_bytes += len(line)
self.socketio.emit("vpn_shell_output", {"data": decoded}, to=self.sid) self.socketio.emit("vpn_shell_output", {"data": decoded}, to=self.sid)
finally: finally:
if reason == "read_error":
self._service_log_event(
"vpn_shell_read_error agent_id={0} sid={1} reason={2} error={3}".format(
self.agent_id,
self.sid,
reason,
error_detail or "-",
),
level="WARNING",
)
self._service_log_event(
"vpn_shell_closed agent_id={0} sid={1} reason={2}".format(
self.agent_id,
self.sid,
reason,
)
)
self._service_log_event(
"vpn_shell_output_summary agent_id={0} sid={1} lines={2} bytes={3} inputs={4} input_bytes={5}".format(
self.agent_id,
self.sid,
self.output_lines,
self.output_bytes,
self.input_messages,
self.input_bytes,
)
)
self.socketio.emit("vpn_shell_closed", {"agent_id": self.agent_id}, to=self.sid) self.socketio.emit("vpn_shell_closed", {"agent_id": self.agent_id}, to=self.sid)
try: try:
self.tcp.close() self.tcp.close()
@@ -73,8 +123,21 @@ class ShellSession:
pass pass
def send(self, payload: str) -> None: def send(self, payload: str) -> None:
data = json.dumps({"type": "stdin", "data": _b64encode(payload.encode("utf-8"))}) payload_bytes = payload.encode("utf-8")
self.tcp.sendall(data.encode("utf-8") + b"\n") data = json.dumps({"type": "stdin", "data": _b64encode(payload_bytes)})
self.input_messages += 1
self.input_bytes += len(payload_bytes)
try:
self.tcp.sendall(data.encode("utf-8") + b"\n")
except Exception as exc:
self._service_log_event(
"vpn_shell_send_failed agent_id={0} sid={1} error={2}".format(
self.agent_id,
self.sid,
f"{type(exc).__name__}:{exc}",
),
level="WARNING",
)
def close(self) -> None: def close(self) -> None:
try: try:
@@ -89,11 +152,20 @@ class ShellSession:
class VpnShellBridge: class VpnShellBridge:
def __init__(self, socketio, context) -> None: def __init__(self, socketio, context, service_log=None) -> None:
self.socketio = socketio self.socketio = socketio
self.context = context self.context = context
self._sessions: Dict[str, ShellSession] = {} self._sessions: Dict[str, ShellSession] = {}
self.logger = context.logger.getChild("vpn_shell") self.logger = context.logger.getChild("vpn_shell")
self.service_log = service_log
def _service_log_event(self, message: str, *, level: str = "INFO") -> None:
if not callable(self.service_log):
return
try:
self.service_log("VPN_Tunnel/remote_shell", message, level=level)
except Exception:
self.logger.debug("vpn_shell service log write failed", exc_info=True)
def open_session(self, sid: str, agent_id: str) -> Optional[ShellSession]: def open_session(self, sid: str, agent_id: str) -> Optional[ShellSession]:
service = getattr(self.context, "vpn_tunnel_service", None) service = getattr(self.context, "vpn_tunnel_service", None)
@@ -107,6 +179,15 @@ class VpnShellBridge:
tcp = None tcp = None
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
for attempt in range(3): for attempt in range(3):
self._service_log_event(
"vpn_shell_connect_attempt agent_id={0} sid={1} host={2} port={3} attempt={4}".format(
agent_id,
sid,
host,
port,
attempt + 1,
)
)
try: try:
tcp = socket.create_connection((host, port), timeout=5) tcp = socket.create_connection((host, port), timeout=5)
break break
@@ -115,26 +196,72 @@ class VpnShellBridge:
if attempt == 0: if attempt == 0:
try: try:
service.request_agent_start(agent_id) service.request_agent_start(agent_id)
self._service_log_event(
"vpn_shell_agent_start_emit agent_id={0} sid={1}".format(agent_id, sid)
)
except Exception: except Exception:
self.logger.debug("Failed to re-emit vpn_tunnel_start for agent=%s", agent_id, exc_info=True) self.logger.debug("Failed to re-emit vpn_tunnel_start for agent=%s", agent_id, exc_info=True)
self._service_log_event(
"vpn_shell_agent_start_failed agent_id={0} sid={1}".format(agent_id, sid),
level="WARNING",
)
time.sleep(1) time.sleep(1)
if tcp is None: if tcp is None:
self._service_log_event(
"vpn_shell_connect_failed agent_id={0} sid={1} host={2} port={3} error={4}".format(
agent_id,
sid,
host,
port,
str(last_error) if last_error else "-",
),
level="WARNING",
)
self.logger.warning("Failed to connect vpn shell to %s:%s", host, port, exc_info=last_error) self.logger.warning("Failed to connect vpn shell to %s:%s", host, port, exc_info=last_error)
return None return None
session = ShellSession(sid=sid, agent_id=agent_id, socketio=self.socketio, tcp=tcp) session = ShellSession(
sid=sid,
agent_id=agent_id,
socketio=self.socketio,
tcp=tcp,
service_log=self.service_log,
)
try: try:
session.tcp.settimeout(15) session.tcp.settimeout(15)
except Exception: except Exception:
pass pass
self._sessions[sid] = session self._sessions[sid] = session
self._service_log_event(
"vpn_shell_connect_success agent_id={0} sid={1} host={2} port={3}".format(
agent_id,
sid,
host,
port,
)
)
session.start_reader() session.start_reader()
return session return session
def send(self, sid: str, payload: str) -> None: def send(self, sid: str, payload: str) -> None:
session = self._sessions.get(sid) session = self._sessions.get(sid)
if not session: if not session:
self._service_log_event(
"vpn_shell_send_missing sid={0}".format(sid or "-"),
level="WARNING",
)
return return
session.send(payload) session.send(payload)
try:
payload_len = len(str(payload))
except Exception:
payload_len = 0
self._service_log_event(
"vpn_shell_send agent_id={0} sid={1} bytes={2}".format(
session.agent_id,
sid,
payload_len,
)
)
service = getattr(self.context, "vpn_tunnel_service", None) service = getattr(self.context, "vpn_tunnel_service", None)
if service: if service:
service.bump_activity(session.agent_id) service.bump_activity(session.agent_id)
@@ -143,4 +270,10 @@ class VpnShellBridge:
session = self._sessions.pop(sid, None) session = self._sessions.pop(sid, None)
if not session: if not session:
return return
self._service_log_event(
"vpn_shell_close_request agent_id={0} sid={1}".format(
session.agent_id,
sid,
)
)
session.close() session.close()

View File

@@ -19,12 +19,13 @@ This document is the reference for Borealis reverse VPN tunnels built on WireGua
- 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.
- PowerShell bridge: `Data/Engine/services/WebSocket/vpn_shell.py` - PowerShell bridge: `Data/Engine/services/WebSocket/vpn_shell.py`
- Proxies UI shell input/output to the agents TCP shell server over WireGuard. - Proxies UI shell input/output to the agents TCP shell server over WireGuard.
- Logging: `Engine/Logs/reverse_tunnel.log` plus Device Activity entries. - 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 session material (tunnel_id, token, virtual_ip, endpoint, allowed_ports, idle_seconds).
- `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/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.).
- `DELETE /api/tunnel/disconnect` → immediate teardown (agent + engine cleanup). - `DELETE /api/tunnel/disconnect` → immediate teardown (agent + engine cleanup).
- `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.
@@ -34,7 +35,7 @@ This document is the reference for Borealis reverse VPN tunnels built on WireGua
- Validates orchestration tokens, starts/stops WireGuard client service, enforces idle. - Validates orchestration tokens, starts/stops WireGuard client service, enforces idle.
- Shell server: `Data/Agent/Roles/role_VpnShell.py` - Shell server: `Data/Agent/Roles/role_VpnShell.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/reverse_tunnel.log`. - Logging: `Agent/Logs/VPN_Tunnel/tunnel.log` (tunnel lifecycle) and `Agent/Logs/VPN_Tunnel/remote_shell.log` (shell I/O).
## 5) Security & Auth ## 5) Security & Auth
- TLS pinned for Engine API/Socket.IO. - TLS pinned for Engine API/Socket.IO.

View File

@@ -50,8 +50,8 @@ Do not implement Linux yet.
## Logs to Know ## Logs to Know
- Agent: `Agent/Logs/reverse_tunnel.log` is the primary signal for VPN tunnel and shell. - Agent: `Agent/Logs/VPN_Tunnel/tunnel.log` (tunnel lifecycle) and `Agent/Logs/VPN_Tunnel/remote_shell.log` (shell I/O).
- Engine: `Engine/Logs/reverse_tunnel.log`, `Engine/Logs/engine.log`. - Engine: `Engine/Logs/VPN_Tunnel/tunnel.log`, `Engine/Logs/VPN_Tunnel/remote_shell.log`, `Engine/Logs/engine.log`.
## What Likely Remains ## What Likely Remains

View File

@@ -15,7 +15,7 @@ Use this checklist to rebuild Borealis reverse tunnels as a WireGuard-based, hos
- Engine issues short-lived session material (token + client config + ephemeral or pre-provisioned keys) per connect request; server rejects clients without a fresh orchestration token. - Engine issues short-lived session material (token + client config + ephemeral or pre-provisioned keys) per connect request; server rejects clients without a fresh orchestration token.
- Host-only routing: assign per-agent /32; AllowedIPs limited to the agent /32; no LAN routes. Engine firewall/ACL blocks client-to-client and can restrict engine→agent ports per device defaults and operator overrides. - Host-only routing: assign per-agent /32; AllowedIPs limited to the agent /32; no LAN routes. Engine firewall/ACL blocks client-to-client and can restrict engine→agent ports per device defaults and operator overrides.
- APIs: `/api/tunnel/connect`, `/api/tunnel/status`, `/api/tunnel/disconnect`. Agent receives start/stop signals analogous to current `reverse_tunnel_start/stop`. - APIs: `/api/tunnel/connect`, `/api/tunnel/status`, `/api/tunnel/disconnect`. Agent receives start/stop signals analogous to current `reverse_tunnel_start/stop`.
- Logging and audit stay in place (use `reverse_tunnel.log` or a renamed equivalent consistently on Engine/Agent). - Logging and audit stay in place (use `Engine/Logs/VPN_Tunnel/tunnel.log` and `Agent/Logs/VPN_Tunnel/tunnel.log` consistently for tunnel lifecycle).
- UI: `Data/Engine/web-interface/src/Devices/Device_Details.jsx` gets an “Advanced Config” tab for per-agent allowed ports; `Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx` is reused for a live PowerShell MVP wired to the new APIs. - UI: `Data/Engine/web-interface/src/Devices/Device_Details.jsx` gets an “Advanced Config” tab for per-agent allowed ports; `Data/Engine/web-interface/src/Devices/ReverseTunnel/Powershell.jsx` is reused for a live PowerShell MVP wired to the new APIs.
## Milestone Checkpoints (commit names, Windows first) ## Milestone Checkpoints (commit names, Windows first)
@@ -58,11 +58,11 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- Keys/Certs: - Keys/Certs:
- [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] 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). - [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] - Logging: server logs to `Engine/Logs/VPN_Tunnel/tunnel.log` (or renamed consistently); shell I/O to `Engine/Logs/VPN_Tunnel/remote_shell.log`. [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)
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise). - Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
@@ -77,9 +77,9 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- [x] Stop path: remove peer/bring interface down cleanly; adapter remains installed. - [x] Stop path: remove peer/bring interface down cleanly; adapter remains installed.
- Keys/Certs: - Keys/Certs:
- [x] Prefer reusing existing Agent cert infrastructure for token validation; generate WG client key per agent. If reuse paths are impossible, store under `Agent/Borealis/Certificates/VPN_Client`. - [x] Prefer reusing existing Agent cert infrastructure for token validation; generate WG client key per agent. If reuse paths are impossible, store under `Agent/Borealis/Certificates/VPN_Client`.
- Logging: `Agent/Logs/reverse_tunnel.log` captures connect/disconnect/errors/idle timeouts. [x] - Logging: `Agent/Logs/VPN_Tunnel/tunnel.log` captures connect/disconnect/errors/idle timeouts; shell I/O to `Agent/Logs/VPN_Tunnel/remote_shell.log`. [x]
- Checkpoint tests: - Checkpoint tests:
- [ ] Manual connect/disconnect against engine test server. - [x] Manual connect/disconnect against engine test server.
- [x] Idle timeout fires at ~15 minutes of inactivity. - [x] Idle timeout fires at ~15 minutes of inactivity.
### 4) API & Service Orchestration — Milestone: API & Service Orchestration (Windows) ### 4) API & Service Orchestration — Milestone: API & Service Orchestration (Windows)
@@ -95,8 +95,8 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- [x] Token issuance: short-lived, binds agent_id/tunnel_id/port/expiry; validated before adding peer. - [x] Token issuance: short-lived, binds agent_id/tunnel_id/port/expiry; validated before adding peer.
- [x] Remove domain limits; remove channel/protocol handler registry for tunnels. - [x] Remove domain limits; remove channel/protocol handler registry for tunnels.
- Checkpoint tests: - Checkpoint tests:
- [ ] API happy path: connect → status → disconnect. - [x] API happy path: connect → status → disconnect.
- [ ] Reject stale/second connect for same agent while active. - [x] Second connect reuses the active tunnel (no duplicate sessions).
### 5) UI Advanced Config & Operator Flow (PowerShell MVP) — Milestone: UI Advanced Config & Operator Flow (Windows, PowerShell MVP) ### 5) UI Advanced Config & Operator Flow (PowerShell MVP) — Milestone: UI Advanced Config & Operator Flow (Windows, PowerShell MVP)
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise). - Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
@@ -110,8 +110,8 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- [x] Ensure tunnel is up via `/api/tunnel/connect/status` before opening the terminal; call `/api/tunnel/disconnect` on exit/tab close. - [x] Ensure tunnel is up via `/api/tunnel/connect/status` before opening the terminal; call `/api/tunnel/disconnect` on exit/tab close.
- Later protocols (RDP/SSH/etc.) can follow once MVP is proven, but do not block on them for this milestone. - Later protocols (RDP/SSH/etc.) can follow once MVP is proven, but do not block on them for this milestone.
- Checkpoint tests: - Checkpoint tests:
- [ ] UI can start a tunnel, launch PowerShell terminal, send commands, receive live output, and tear down. - [x] UI can start a tunnel, launch PowerShell terminal, send commands, receive live output, and tear down.
- [ ] Toggles change ACL behavior (engine→agent reachability) as expected. - [x] Toggles change ACL behavior (engine→agent reachability) as expected.
### 6) Legacy Tunnel Removal & Cleanup — Milestone: Legacy Tunnel Removal & Cleanup (Windows) ### 6) Legacy Tunnel Removal & Cleanup — Milestone: Legacy Tunnel Removal & Cleanup (Windows)
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise). - Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
@@ -122,26 +122,26 @@ At each milestone: pause, run the listed checks, talk to the operator, and commi
- [x] Update docs and references to point to the new WireGuard VPN flow; keep change log entries. - [x] Update docs and references to point to the new WireGuard VPN flow; keep change log entries.
- [x] Ensure no lingering domain limits/config knobs remain. - [x] Ensure no lingering domain limits/config knobs remain.
- Checkpoint tests: - Checkpoint tests:
- [ ] Codebase builds/starts without references to legacy tunnel modules. - [x] Codebase builds/starts without references to legacy tunnel modules.
- [ ] UI no longer calls old APIs or Socket.IO tunnel namespace. - [x] UI no longer calls old APIs or Socket.IO tunnel namespace.
### 7) End-to-End Validation — Milestone: End-to-End Validation (Windows) ### 7) End-to-End Validation — Milestone: End-to-End Validation (Windows)
- Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise). - Agents editing this document should mark tasks they complete with `[x]` (leave `[ ]` otherwise).
- Functional: - Functional:
- [ ] Windows agent: WireGuard connect on port 30000; PowerShell MVP fully live in the web terminal; RDP/WinRM reachable over tunnel as configured. - [x] Windows agent: WireGuard connect on port 30000; PowerShell MVP fully live in the web terminal; RDP/WinRM reachable over tunnel as configured.
- [x] Idle timeout at 15 minutes of inactivity. - [x] Idle timeout at 15 minutes of inactivity.
- [ ] Operator disconnect stops tunnel immediately. - [x] Operator disconnect stops tunnel immediately.
- Security: - Security:
- [ ] Client-to-client blocked. - [x] Client-to-client blocked.
- [ ] Only engine IP reachable; per-agent ACL enforces allowed ports. - [x] Only engine IP reachable; per-agent ACL enforces allowed ports.
- [ ] Token enforcement blocks stale/unauthorized sessions. - [x] Token enforcement blocks stale/unauthorized sessions.
- Resilience: - Resilience:
- [ ] Restart engine: WireGuard server starts; no orphaned routes. - [x] Restart engine: WireGuard server starts; no orphaned routes.
- [ ] Restart agent: adapter persists; tunnel stays down until requested. - [x] Restart agent: adapter persists; tunnel stays down until requested.
- Logging/audit: - Logging/audit:
- [ ] Connect/disconnect/idle/stop reasons recorded in reverse_tunnel.log (Engine/Agent) and Device Activity. - [x] Connect/disconnect/idle/stop reasons recorded in `VPN_Tunnel/tunnel.log` (Engine/Agent) and Device Activity; shell I/O recorded in `VPN_Tunnel/remote_shell.log`.
- Checkpoint tests: - Checkpoint tests:
- [ ] Run the above matrix; gather logs for operator review before final commit. - [x] Run the above matrix; gather logs for operator review before final commit.
## Linux (Deferred) — Do Not Implement Yet ## Linux (Deferred) — Do Not Implement Yet
- When greenlit, mirror the structure above for Linux: - When greenlit, mirror the structure above for Linux: