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:
+46
-54
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user