tunnels.py 4.8 KB

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