Files
Borealis-Github-Replica/Data/Engine/services/API/devices/vnc.py

339 lines
12 KiB
Python

# ======================================================
# 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)