fix: harden dashboard auth and terminal flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m29s

Tighten terminal websocket auth and proxy trust handling while making file-backed auth/RBAC writes atomic to reduce high-impact security and persistence risks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gan, Jimmy
2026-03-06 23:53:58 +08:00
parent c9f06fbbbb
commit 44f0ed5d04
9 changed files with 337 additions and 175 deletions
+49 -33
View File
@@ -1,16 +1,18 @@
import asyncio
import struct
import logging
import time
from fastapi import WebSocket, WebSocketDisconnect, Query
from collections import Counter
from fastapi import WebSocket, Query
import asyncssh
import jwt
import config
from auth import get_current_user_ws
logger = logging.getLogger(__name__)
MAX_SESSIONS = 5
_active_sessions: set = set()
MAX_SESSIONS_PER_USER = 2
_session_lock = asyncio.Lock()
_active_sessions: dict[int, str] = {}
HOSTS = {
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH},
@@ -31,43 +33,57 @@ except Exception:
logger.error("SSH known_hosts file not found at %s — terminal connections will be refused", config.SSH_KNOWN_HOSTS)
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
await websocket.accept()
async def _reserve_session(session_id: int, username: str) -> tuple[bool, str | None, int | None]:
async with _session_lock:
if len(_active_sessions) >= MAX_SESSIONS:
return False, "Too many sessions", 1013
user_sessions = Counter(_active_sessions.values())
if user_sessions[username] >= MAX_SESSIONS_PER_USER:
return False, "Too many sessions for user", 1013
_active_sessions[session_id] = username
return True, None, session_id
async def _release_session(session_id: int | None):
if session_id is None:
return
async with _session_lock:
_active_sessions.pop(session_id, None)
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str | None = Query(None)):
if host not in HOSTS:
await websocket.close(code=1008, reason="Unknown host")
return
if not token:
await websocket.close(code=1008, reason="Unauthorized")
return
# Auth: expect token as first text message
try:
first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5)
payload = jwt.decode(first_msg, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "access":
await websocket.close(code=1008, reason="Unauthorized")
return
username = payload.get("sub")
role = payload.get("role", "admin")
if username is None:
await websocket.close(code=1008, reason="Unauthorized")
return
# RBAC: check terminal page access
from rbac import get_pages
pages = get_pages(username, role)
if pages != "*" and "terminal" not in pages:
await websocket.close(code=1008, reason="Access denied")
return
user = await get_current_user_ws(token)
except Exception:
await websocket.close(code=1008, reason="Unauthorized")
return
if user.pages != "*" and "terminal" not in user.pages:
await websocket.close(code=1008, reason="Access denied")
return
if not _known_hosts_available:
await websocket.close(code=1011, reason="SSH host verification unavailable")
return
if len(_active_sessions) >= MAX_SESSIONS:
await websocket.close(code=1013, reason="Too many sessions")
return
session_id = id(websocket)
_active_sessions.add(session_id)
reserved, reason, session_or_code = await _reserve_session(session_id, user.username)
if not reserved:
await websocket.close(code=session_or_code, reason=reason)
return
session_id = session_or_code
h = HOSTS.get(host, HOSTS["nas"])
await websocket.accept()
h = HOSTS[host]
try:
conn = await asyncssh.connect(
h["host"], username=h["user"],
@@ -76,7 +92,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
keepalive_count_max=3,
)
except Exception:
_active_sessions.discard(session_id)
await _release_session(session_id)
await websocket.close(code=1011, reason="SSH connection failed")
return
@@ -93,7 +109,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
if not data:
break
await websocket.send_bytes(data)
except (asyncssh.Error, WebSocketDisconnect, asyncio.CancelledError):
except (asyncssh.Error, asyncio.CancelledError):
pass
read_task = asyncio.create_task(read_ssh())
@@ -113,11 +129,11 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
if msg["text"] == "__ping__":
continue
process.stdin.write(msg["text"].encode())
except WebSocketDisconnect:
except Exception:
pass
finally:
read_task.cancel()
process.close()
finally:
conn.close()
_active_sessions.discard(session_id)
await _release_session(session_id)