Files
Nexus/server/api/rdp.py
T

113 lines
3.5 KiB
Python
Raw Normal View History

"""Browser RDP — Guacamole WebSocket tunnel to guacd (no session audit)."""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Path, WebSocket, WebSocketDisconnect
from server.config import settings
from server.domain.models import Admin
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
2026-06-10 20:58:46 +08:00
from server.infrastructure.database.rdp_host_repo import RdpHostRepositoryImpl
from server.infrastructure.guacamole.tunnel import bridge_websocket_to_guacd
logger = logging.getLogger("nexus.rdp")
router = APIRouter()
async def _verify_rdp_access_token(token: str) -> Optional[Admin]:
"""Verify normal access JWT for RDP (reject webssh-only tokens)."""
try:
import jwt as pyjwt
payload = pyjwt.decode(
token,
settings.SECRET_KEY,
algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
except Exception:
return None
if payload.get("purpose") == "webssh":
return None
admin_id = payload.get("sub")
if not admin_id:
return None
try:
admin_id = int(admin_id)
except (TypeError, ValueError):
return None
async with AsyncSessionLocal() as session:
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
if not admin or not admin.is_active:
return None
token_tv = payload.get("tv") or 0
if token_tv != (admin.token_version or 0):
return None
return admin
@router.websocket("/ws/rdp/hosts/{host_id}/tunnel/{access_token}")
async def rdp_guacamole_ws(
websocket: WebSocket,
2026-06-10 20:58:46 +08:00
host_id: int = Path(...),
access_token: str = Path(..., description="URL-encoded JWT (not query — Guacamole appends ?connect)"),
):
"""Transparent Guacamole tunnel to guacd after JWT + RDP host lookup.
Token is in the path because guacamole-common-js ``Client.connect()`` always
appends ``?hostname=...`` to the tunnel URL; a ``?token=`` query would break JWT parsing.
"""
from urllib.parse import unquote
token = unquote(access_token).strip()
if not token:
await websocket.close(code=4001, reason="Missing JWT token")
return
admin = await _verify_rdp_access_token(token)
if not admin:
await websocket.close(code=4401, reason="Invalid or expired JWT token")
return
if not settings.GUACD_HOST:
await websocket.close(code=4503, reason="guacd not configured")
return
async with AsyncSessionLocal() as session:
2026-06-10 20:58:46 +08:00
repo = RdpHostRepositoryImpl(session)
row = await repo.get_by_id(host_id)
if not row:
await websocket.close(code=4404, reason="RDP host not found")
return
await websocket.accept(subprotocol="guacamole")
try:
await bridge_websocket_to_guacd(
websocket,
settings.GUACD_HOST,
settings.GUACD_PORT,
)
except WebSocketDisconnect:
pass
except OSError as exc:
2026-06-10 20:58:46 +08:00
logger.warning("RDP guacd bridge failed host_id=%s: %s", host_id, exc)
try:
await websocket.close(code=1011, reason="guacd unreachable")
except Exception:
pass
except Exception:
2026-06-10 20:58:46 +08:00
logger.exception("RDP tunnel error host_id=%s", host_id)
try:
await websocket.close(code=1011, reason="RDP tunnel error")
except Exception:
pass