P2迭代: 危险命令检测 + DB会话合并 + API密钥修复 + 安全审计 (6项)

P2-14: agent.py _verify_api_key() 重写文档并明确两步验证逻辑,
       全局密钥匹配直接通过,非匹配密钥放行至handler做per-server验证
P2-15: websocket.py 删除已废弃的 connected_clients=[] 别名,
       health.py 已使用 manager.client_count
P2-16: webssh.py 合并4个AsyncSessionLocal()为1个共享session(预接受操作),
       命令日志保留独立session(长连接WebSocket不能持有单session)
P2-18: 新增危险命令检测 (check_dangerous_command),识别 rm -rf /、
       fork bomb、dd写裸设备、mkfs等模式,scripts.py + webssh.py集成
P2-19: install.py 状态文件不再存储db_pass明文(步骤5已删除文件),
       审计确认所有logger调用无敏感数据泄露
P2-17: httpOnly cookie迁移标记为延期(需前后端+WebSocket全面重构,
       当前JWT+updated_at+CORS限制方案已足够安全)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-22 23:18:08 +08:00
parent d744d7df8e
commit 341d16fd6d
6 changed files with 108 additions and 73 deletions
+46 -54
View File
@@ -1,14 +1,4 @@
"""Nexus — Web SSH Terminal WebSocket Endpoint
W3: WebSocket endpoint /ws/terminal/{token}
W5: Koko message protocol (TERMINAL_INIT/DATA/RESIZE/CLOSE)
Flow:
1. Client connects via WebSocket with JWT token in query param
2. Server verifies JWT, looks up server by ID
3. Server creates asyncssh connection to target server
4. Bidirectional data relay: client ↔ WebSocket ↔ asyncssh
5. Session tracked in SshSession + commands logged to CommandLog
"""
import asyncio
@@ -21,12 +11,13 @@ from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query, Path
from server.config import settings
from server.domain.models import SshSession, CommandLog, Server
from server.domain.models import SshSession, CommandLog, Server, AuditLog
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.ssh_session_repo import SshSessionRepositoryImpl, CommandLogRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
logger = logging.getLogger("nexus.webssh")
@@ -41,7 +32,6 @@ MSG_ERROR = "ERROR"
# Command logging configuration
COMMAND_LOG_MAX_LENGTH = 2000
COMMAND_LOG_BUFFER_LINES = 1 # Log every line immediately
async def _verify_webssh_token(token: str):
@@ -105,53 +95,43 @@ async def terminal_ws(
await websocket.close(code=4003, reason="Token not authorized for this server")
return
# ── Lookup target server ──
async with AsyncSessionLocal() as session:
server_repo = ServerRepositoryImpl(session)
# ── P2-16: Consolidated pre-accept DB operations in one session ──
# server lookup + audit log + SSH session record all share one session,
# which is closed before the long-lived WebSocket relay begins.
client_ip = websocket.client.host if websocket.client else "unknown"
session_id = str(uuid.uuid4())
server: Optional[Server] = None
async with AsyncSessionLocal() as db:
server_repo = ServerRepositoryImpl(db)
server = await server_repo.get_by_id(server_id)
if not server:
await websocket.close(code=4004, reason=f"Server {server_id} not found")
return
if not server:
# Can't close WebSocket before accept with a reason in standard way
# Just return — the client will time out
return
# ── Server-level authorization check ──
# Verify the server has valid SSH credentials configured
if not server.domain:
await websocket.close(code=4003, reason=f"Server {server.name} has no domain/IP configured")
return
# ── Server-level authorization check ──
if not server.domain:
return
if server.auth_method == "key" and not server.ssh_key_path and not server.ssh_key_private:
return
if server.auth_method == "password" and not server.password:
return
if server.auth_method == "key" and not server.ssh_key_path and not server.ssh_key_private:
await websocket.close(code=4003, reason=f"Server {server.name} has no SSH key configured")
return
if server.auth_method == "password" and not server.password:
await websocket.close(code=4003, reason=f"Server {server.name} has no SSH password configured")
return
# ── Audit: WebSSH session start ──
async with AsyncSessionLocal() as session:
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import AuditLog
audit_repo = AuditLogRepositoryImpl(session)
# Audit: WebSSH session start
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="webssh_connect",
target_type="server",
target_id=server.id,
detail=f"WebSSH to {server.name} ({server.domain})",
ip_address=websocket.client.host if websocket.client else "",
ip_address=client_ip,
))
# ── Accept WebSocket ──
await websocket.accept()
client_ip = websocket.client.host if websocket.client else "unknown"
session_id = str(uuid.uuid4())
logger.info(f"WebSSH: admin={admin.username} connecting to server={server.name} ({server.domain})")
# ── Create SSH session record ──
async with AsyncSessionLocal() as session:
ssh_session_repo = SshSessionRepositoryImpl(session)
# Create SSH session record
ssh_session_repo = SshSessionRepositoryImpl(db)
ssh_session = SshSession(
id=session_id,
server_id=server.id,
@@ -161,6 +141,11 @@ async def terminal_ws(
)
await ssh_session_repo.create(ssh_session)
# ── Accept WebSocket ──
await websocket.accept()
logger.info(f"WebSSH: admin={admin.username} connecting to server={server.name} ({server.domain})")
# ── Establish SSH connection ──
try:
conn = await ssh_pool.acquire(server)
@@ -189,7 +174,7 @@ async def terminal_ws(
command_buffer = "" # Buffer for command logging
async def _read_shell_output():
"""Read SSH shell output send to WebSocket client"""
"""Read SSH shell output -> send to WebSocket client"""
try:
async for data in shell.stdout:
await websocket.send_json({
@@ -200,7 +185,7 @@ async def terminal_ws(
pass
async def _read_websocket_input():
"""Read WebSocket client input write to SSH shell"""
"""Read WebSocket client input -> write to SSH shell"""
nonlocal command_buffer
try:
while True:
@@ -226,6 +211,10 @@ async def terminal_ws(
if "\r" in data or "\n" in data:
cmd = command_buffer.strip()
if cmd:
# P2-18: Check for dangerous commands
from server.api.dependencies import check_dangerous_command
check_dangerous_command(cmd)
await _log_command(
session_id=session_id,
server_id=server.id,
@@ -293,8 +282,6 @@ async def terminal_ws(
# Audit: WebSSH session end
try:
async with AsyncSessionLocal() as audit_session:
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import AuditLog
audit_repo = AuditLogRepositoryImpl(audit_session)
await audit_repo.create(AuditLog(
admin_username=admin.username,
@@ -302,7 +289,7 @@ async def terminal_ws(
target_type="server",
target_id=server.id,
detail=f"WebSSH disconnected from {server.name}",
ip_address=websocket.client.host if websocket.client else "",
ip_address=client_ip,
))
except Exception:
pass
@@ -319,7 +306,12 @@ async def _close_ssh_session(session_id: str):
async def _log_command(session_id: str, server_id: int, admin_id: int, command: str, remote_addr: str):
"""Log a command to CommandLog"""
"""Log a command to CommandLog
Uses its own session per command — the WebSocket connection is long-lived
(potentially hours), so we can't hold a single DB session open for the
entire duration. Each command gets its own short-lived session.
"""
try:
async with AsyncSessionLocal() as session:
cmd_log_repo = CommandLogRepositoryImpl(session)
@@ -332,4 +324,4 @@ async def _log_command(session_id: str, server_id: int, admin_id: int, command:
)
await cmd_log_repo.create(log)
except Exception as e:
logger.error(f"Failed to log command: {e}")
logger.error(f"Failed to log command: {e}")