mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2026-02-07 01:00:32 -07:00
Removed RDP in favor of VNC / Made WireGuard Tunnel Persistent
This commit is contained in:
338
Data/Engine/services/API/devices/vnc.py
Normal file
338
Data/Engine/services/API/devices/vnc.py
Normal file
@@ -0,0 +1,338 @@
|
||||
# ======================================================
|
||||
# Data\Engine\services\API\devices\vnc.py
|
||||
# Description: VNC session bootstrap for noVNC WebSocket tunnels.
|
||||
#
|
||||
# API Endpoints (if applicable):
|
||||
# - POST /api/vnc/establish (Token Authenticated) - Establish a VNC session for noVNC.
|
||||
# - POST /api/vnc/disconnect (Token Authenticated) - Disconnect the operator VNC session.
|
||||
# - POST /api/vnc/session (Token Authenticated) - Legacy alias for establish.
|
||||
# ======================================================
|
||||
|
||||
"""VNC session bootstrap endpoints for the Borealis Engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from flask import Blueprint, jsonify, request, session
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from ...RemoteDesktop.vnc_proxy import VNC_WS_PATH, ensure_vnc_proxy
|
||||
from .tunnel import _get_tunnel_service
|
||||
|
||||
if False: # pragma: no cover - hint for type checkers
|
||||
from .. import EngineServiceAdapters
|
||||
|
||||
|
||||
def _current_user(app) -> Optional[Dict[str, str]]:
|
||||
username = session.get("username")
|
||||
role = session.get("role") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
token = request.cookies.get("borealis_auth")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
serializer = URLSafeTimedSerializer(app.secret_key or "borealis-dev-secret", salt="borealis-auth")
|
||||
token_ttl = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30))
|
||||
data = serializer.loads(token, max_age=token_ttl)
|
||||
username = data.get("u")
|
||||
role = data.get("r") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
except (BadSignature, SignatureExpired, Exception):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _require_login(app) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||
user = _current_user(app)
|
||||
if not user:
|
||||
return {"error": "unauthorized"}, 401
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
try:
|
||||
return str(value).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _infer_endpoint_host(req) -> str:
|
||||
forwarded = (req.headers.get("X-Forwarded-Host") or req.headers.get("X-Original-Host") or "").strip()
|
||||
host = forwarded.split(",")[0].strip() if forwarded else (req.host or "").strip()
|
||||
if not host:
|
||||
return ""
|
||||
try:
|
||||
parsed = urlsplit(f"//{host}")
|
||||
if parsed.hostname:
|
||||
return parsed.hostname
|
||||
except Exception:
|
||||
return host
|
||||
return host
|
||||
|
||||
|
||||
def _is_secure(req) -> bool:
|
||||
if req.is_secure:
|
||||
return True
|
||||
forwarded = (req.headers.get("X-Forwarded-Proto") or "").split(",")[0].strip().lower()
|
||||
return forwarded == "https"
|
||||
|
||||
|
||||
def _generate_vnc_password() -> str:
|
||||
# UltraVNC uses the first 8 characters for VNC auth; keep the token to 8 for compatibility.
|
||||
return secrets.token_hex(4)
|
||||
|
||||
|
||||
def _load_vnc_password(adapters: "EngineServiceAdapters", agent_id: str) -> Optional[str]:
|
||||
conn = adapters.db_conn_factory()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT agent_vnc_password FROM devices WHERE agent_id=? ORDER BY last_seen DESC LIMIT 1",
|
||||
(agent_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
return str(row[0]).strip()
|
||||
except Exception:
|
||||
adapters.context.logger.debug("Failed to load agent VNC password", exc_info=True)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _store_vnc_password(adapters: "EngineServiceAdapters", agent_id: str, password: str) -> None:
|
||||
conn = adapters.db_conn_factory()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE devices SET agent_vnc_password=? WHERE agent_id=?",
|
||||
(password, agent_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
adapters.context.logger.debug("Failed to store agent VNC password", exc_info=True)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_vnc(app, adapters: "EngineServiceAdapters") -> None:
|
||||
blueprint = Blueprint("vnc", __name__)
|
||||
logger = adapters.context.logger.getChild("vnc.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("VNC", message, level=level)
|
||||
except Exception:
|
||||
logger.debug("vnc 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()
|
||||
|
||||
def _issue_session(agent_id: str, operator_id: Optional[str]) -> Tuple[Dict[str, Any], int]:
|
||||
tunnel_service = _get_tunnel_service(adapters)
|
||||
session_payload = tunnel_service.session_payload(agent_id, include_token=False)
|
||||
if not session_payload:
|
||||
try:
|
||||
session_payload = tunnel_service.connect(
|
||||
agent_id=agent_id,
|
||||
operator_id=operator_id,
|
||||
endpoint_host=_infer_endpoint_host(request),
|
||||
)
|
||||
except Exception:
|
||||
return {"error": "tunnel_down"}, 409
|
||||
|
||||
vnc_port = int(getattr(adapters.context, "vnc_port", 5900))
|
||||
raw_ports = session_payload.get("allowed_ports") or []
|
||||
allowed_ports = []
|
||||
for value in raw_ports:
|
||||
try:
|
||||
allowed_ports.append(int(value))
|
||||
except Exception:
|
||||
continue
|
||||
if vnc_port not in allowed_ports:
|
||||
return {"error": "vnc_not_allowed", "vnc_port": vnc_port}, 403
|
||||
|
||||
virtual_ip = _normalize_text(session_payload.get("virtual_ip"))
|
||||
host = virtual_ip.split("/")[0] if virtual_ip else ""
|
||||
if not host:
|
||||
return {"error": "virtual_ip_missing"}, 500
|
||||
|
||||
vnc_password = _load_vnc_password(adapters, agent_id)
|
||||
if not vnc_password:
|
||||
vnc_password = _generate_vnc_password()
|
||||
_store_vnc_password(adapters, agent_id, vnc_password)
|
||||
if len(vnc_password) > 8:
|
||||
vnc_password = vnc_password[:8]
|
||||
_store_vnc_password(adapters, agent_id, vnc_password)
|
||||
|
||||
registry = ensure_vnc_proxy(adapters.context, logger=logger)
|
||||
if registry is None:
|
||||
return {"error": "vnc_proxy_unavailable"}, 503
|
||||
|
||||
_service_log_event(
|
||||
"vnc_establish_request agent_id={0} operator={1} remote={2}".format(
|
||||
agent_id,
|
||||
operator_id or "-",
|
||||
_request_remote() or "-",
|
||||
)
|
||||
)
|
||||
|
||||
vnc_session = registry.create(
|
||||
agent_id=agent_id,
|
||||
host=host,
|
||||
port=vnc_port,
|
||||
operator_id=operator_id,
|
||||
)
|
||||
|
||||
emit_agent = getattr(adapters.context, "emit_agent_event", None)
|
||||
payload = {
|
||||
"agent_id": agent_id,
|
||||
"port": vnc_port,
|
||||
"allowed_ips": session_payload.get("allowed_ips"),
|
||||
"virtual_ip": host,
|
||||
"password": vnc_password,
|
||||
"reason": "vnc_session_start",
|
||||
}
|
||||
agent_socket_ready = True
|
||||
if callable(emit_agent):
|
||||
agent_socket_ready = bool(emit_agent(agent_id, "vnc_start", payload))
|
||||
if agent_socket_ready:
|
||||
_service_log_event(
|
||||
"vnc_start_emit agent_id={0} port={1} virtual_ip={2}".format(
|
||||
agent_id,
|
||||
vnc_port,
|
||||
host or "-",
|
||||
)
|
||||
)
|
||||
else:
|
||||
_service_log_event(
|
||||
"vnc_start_emit_failed agent_id={0} port={1}".format(
|
||||
agent_id,
|
||||
vnc_port,
|
||||
),
|
||||
level="WARNING",
|
||||
)
|
||||
if not agent_socket_ready:
|
||||
return {"error": "agent_socket_missing"}, 409
|
||||
|
||||
ws_scheme = "wss" if _is_secure(request) else "ws"
|
||||
ws_host = _infer_endpoint_host(request)
|
||||
ws_port = int(getattr(adapters.context, "vnc_ws_port", 4823))
|
||||
ws_url = f"{ws_scheme}://{ws_host}:{ws_port}{VNC_WS_PATH}"
|
||||
|
||||
_service_log_event(
|
||||
"vnc_session_ready agent_id={0} token={1} expires_at={2}".format(
|
||||
agent_id,
|
||||
vnc_session.token[:8],
|
||||
int(vnc_session.expires_at),
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
{
|
||||
"token": vnc_session.token,
|
||||
"ws_url": ws_url,
|
||||
"expires_at": int(vnc_session.expires_at),
|
||||
"virtual_ip": host,
|
||||
"tunnel_id": session_payload.get("tunnel_id"),
|
||||
"engine_virtual_ip": session_payload.get("engine_virtual_ip"),
|
||||
"allowed_ports": session_payload.get("allowed_ports"),
|
||||
"vnc_password": vnc_password,
|
||||
"vnc_port": vnc_port,
|
||||
},
|
||||
200,
|
||||
)
|
||||
|
||||
@blueprint.route("/api/vnc/establish", methods=["POST"])
|
||||
def vnc_establish():
|
||||
requirement = _require_login(app)
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
user = _current_user(app) or {}
|
||||
operator_id = user.get("username") or None
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
agent_id = _normalize_text(body.get("agent_id"))
|
||||
|
||||
if not agent_id:
|
||||
return jsonify({"error": "agent_id_required"}), 400
|
||||
|
||||
payload, status = _issue_session(agent_id, operator_id)
|
||||
return jsonify(payload), status
|
||||
|
||||
@blueprint.route("/api/vnc/session", methods=["POST"])
|
||||
def vnc_session():
|
||||
return vnc_establish()
|
||||
|
||||
@blueprint.route("/api/vnc/disconnect", methods=["POST"])
|
||||
def vnc_disconnect():
|
||||
requirement = _require_login(app)
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
user = _current_user(app) or {}
|
||||
operator_id = user.get("username") or None
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
agent_id = _normalize_text(body.get("agent_id"))
|
||||
reason = _normalize_text(body.get("reason") or "operator_disconnect")
|
||||
|
||||
if not agent_id:
|
||||
return jsonify({"error": "agent_id_required"}), 400
|
||||
|
||||
registry = ensure_vnc_proxy(adapters.context, logger=logger)
|
||||
revoked = 0
|
||||
if registry is not None:
|
||||
try:
|
||||
revoked = registry.revoke_agent(agent_id)
|
||||
except Exception:
|
||||
revoked = 0
|
||||
|
||||
emit_agent = getattr(adapters.context, "emit_agent_event", None)
|
||||
if callable(emit_agent):
|
||||
try:
|
||||
emit_agent(agent_id, "vnc_stop", {"agent_id": agent_id, "reason": reason})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_service_log_event(
|
||||
"vnc_disconnect agent_id={0} operator={1} reason={2} revoked={3}".format(
|
||||
agent_id,
|
||||
operator_id or "-",
|
||||
reason or "-",
|
||||
revoked,
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({"status": "disconnected", "revoked": revoked, "reason": reason}), 200
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
Reference in New Issue
Block a user