tunnels.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. """
  2. Superadmin tunnel management API endpoints.
  3. """
  4. from typing import Optional
  5. from fastapi import APIRouter, Depends, HTTPException, status
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from pydantic import BaseModel
  8. from app.core.database import get_db
  9. from app.core.permissions import require_permission
  10. from app.models.device import Device
  11. from app.models.user import User
  12. from app.services.tunnel_service import tunnel_service
  13. from sqlalchemy import select
  14. router = APIRouter(prefix="/tunnels", tags=["superadmin-tunnels"])
  15. class TunnelEnableResponse(BaseModel):
  16. """Response when enabling tunnel"""
  17. session_uuid: str
  18. device_id: str
  19. tunnel_type: str
  20. status: str
  21. class TunnelStatusResponse(BaseModel):
  22. """Tunnel session status response"""
  23. session_uuid: str
  24. status: str # "waiting" | "ready" | "failed"
  25. device_tunnel_port: Optional[int] = None
  26. ttyd_port: Optional[int] = None
  27. tunnel_url: Optional[str] = None
  28. @router.post("/devices/{device_id}/{tunnel_type}")
  29. @require_permission("devices", "manage")
  30. async def enable_tunnel(
  31. device_id: int,
  32. tunnel_type: str,
  33. current_user: User = Depends(require_permission("devices", "manage")),
  34. db: AsyncSession = Depends(get_db)
  35. ) -> TunnelEnableResponse:
  36. """
  37. Enable SSH or Dashboard tunnel for device.
  38. Creates session and triggers device to connect tunnel.
  39. Args:
  40. device_id: Device numeric ID
  41. tunnel_type: "ssh" or "dashboard"
  42. Returns:
  43. session_uuid: UUID for polling status
  44. """
  45. if tunnel_type not in ["ssh", "dashboard"]:
  46. raise HTTPException(
  47. status_code=status.HTTP_400_BAD_REQUEST,
  48. detail="tunnel_type must be 'ssh' or 'dashboard'"
  49. )
  50. # Get device
  51. result = await db.execute(
  52. select(Device).where(Device.id == device_id)
  53. )
  54. device = result.scalar_one_or_none()
  55. if not device:
  56. raise HTTPException(
  57. status_code=status.HTTP_404_NOT_FOUND,
  58. detail="Device not found"
  59. )
  60. # Create tunnel session
  61. session = tunnel_service.create_session(
  62. device_id=device.mac_address,
  63. admin_user=current_user.email,
  64. tunnel_type=tunnel_type
  65. )
  66. # Update device config to enable tunnel
  67. if device.config is None:
  68. device.config = {}
  69. tunnel_key = f"{tunnel_type}_tunnel"
  70. if tunnel_key not in device.config:
  71. device.config[tunnel_key] = {}
  72. device.config[tunnel_key]["enabled"] = True
  73. # Mark as modified (SQLAlchemy JSON field)
  74. from sqlalchemy.orm import attributes
  75. attributes.flag_modified(device, "config")
  76. await db.commit()
  77. return TunnelEnableResponse(
  78. session_uuid=session.uuid,
  79. device_id=device.mac_address,
  80. tunnel_type=tunnel_type,
  81. status="waiting"
  82. )
  83. @router.get("/sessions/{session_uuid}/status")
  84. async def get_tunnel_status(
  85. session_uuid: str,
  86. current_user: User = Depends(require_permission("devices", "view"))
  87. ) -> TunnelStatusResponse:
  88. """
  89. Poll tunnel session status.
  90. Returns:
  91. - waiting: Device not yet connected
  92. - ready: Tunnel connected, ttyd spawned
  93. - failed: Session expired or failed
  94. """
  95. session = tunnel_service.get_session(session_uuid)
  96. if not session:
  97. raise HTTPException(
  98. status_code=status.HTTP_404_NOT_FOUND,
  99. detail="Session not found or expired"
  100. )
  101. # Build response
  102. response = TunnelStatusResponse(
  103. session_uuid=session.uuid,
  104. status=session.status,
  105. device_tunnel_port=session.device_tunnel_port,
  106. ttyd_port=session.ttyd_port
  107. )
  108. # If ready, spawn ttyd if not already spawned
  109. if session.status == "ready" and session.device_tunnel_port:
  110. if not session.ttyd_port:
  111. try:
  112. # Spawn ttyd
  113. ttyd_port = tunnel_service.spawn_ttyd(
  114. session_uuid=session.uuid,
  115. device_tunnel_port=session.device_tunnel_port
  116. )
  117. response.ttyd_port = ttyd_port
  118. response.tunnel_url = f"/admin/{session.tunnel_type}/{session.uuid}"
  119. except Exception as e:
  120. print(f"[tunnel] Failed to spawn ttyd: {e}")
  121. session.status = "failed"
  122. response.status = "failed"
  123. else:
  124. response.tunnel_url = f"/admin/{session.tunnel_type}/{session.uuid}"
  125. return response
  126. @router.post("/sessions/{session_uuid}/heartbeat")
  127. async def session_heartbeat(
  128. session_uuid: str,
  129. current_user: User = Depends(require_permission("devices", "view"))
  130. ):
  131. """
  132. Browser sends heartbeat every 30 seconds to keep session alive.
  133. """
  134. success = tunnel_service.update_heartbeat(session_uuid)
  135. if not success:
  136. raise HTTPException(
  137. status_code=status.HTTP_404_NOT_FOUND,
  138. detail="Session not found"
  139. )
  140. return {"success": True}