Files
Nexus/server/api/dependencies.py
T
Your Name cdd0be328a fix: 六轮深度扫描 — 47项Bug修复、安全加固、死代码清理
Critical runtime bugs:
- terminal.html WebSSH完全不可用(URL前缀/JSON解析/Content-Type三处错误)
- servers.py路由遮蔽:/logs被/{id}拦截,3个前端页面同步日志查询失败
- scripts.html startExecPoll()→startExecPolling(),长任务快速执行崩溃
- agent.py {value!r!s:.50}格式串非法,agent发非数值时ValueError
- alerts.html d.daily.reduce()无null检查,API返回空数据时TypeError

Resource leak / stability:
- websocket.py僵尸连接未关闭TCP,文件描述符泄漏
- websocket.py _last_alert_time字典无限增长(加1小时过期清理)
- asyncssh_pool.py全忙时超过MAX_CONNECTIONS无限增长
- self_monitor.py Telegram告警无冷却,宕机时每30秒刷屏
- schedule_runner.py一次性调度执行超60秒会重复触发
- 限速脚本EXPIRE每次重置窗口可绕过(改用Lua原子脚本)

Security:
- JWT access token加token_version声明,改密码后旧token立失效(零宽限)
- INSTALL_MODE导入时常量→动态函数,安装后JWT认证不再残留禁用
- install.py /lock端点加管理员存在性验证,防止阻断安装
- ServerUpdate schema移除connectivity只读字段,防止伪造连接状态

Frontend fixes:
- doExec()缺r.ok检查、commands.html null检查
- _server_to_dict()补last_checked_at+ssh_key_public
- _field_match()逗号cron表达式修复
- alerts类型显示、SSH会话名称、搜索高亮定位
- 一次性/循环定时任务(run_mode+fire_at+自动禁用)

Dead code removed (400+ lines):
- SyncService batch_push/_push_single等5个方法(零调用者)
- 5个未使用schema(SyncCommands/SyncConfig/SyncSftp/FileDeploy/PaginatedResponse)
- 6个零调用service方法、3个无前端API端点
- 4个未使用import

Schema migrations:
- push_schedules: run_mode + fire_at列,cron_expr改NULL
- servers: 7个新列 + ssh_key_private/public VARCHAR(500)→TEXT

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-24 16:26:40 +08:00

180 lines
6.9 KiB
Python

"""Nexus — FastAPI Dependency Injection
Wires Repository implementations → Service instances → API route handlers.
All DI occurs here; routes never import infrastructure directly.
D7 Fix: Service factories now read session from request.state.db (set by
DbSessionMiddleware) instead of creating unclosed AsyncSessionLocal() instances.
This fixes the P0 session leak bug where 4 service factories never closed sessions.
"""
from typing import AsyncGenerator
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
from server.infrastructure.database.script_repo import ScriptRepositoryImpl, ScriptExecutionRepositoryImpl
from server.infrastructure.database.db_credential_repo import DbCredentialRepositoryImpl
from server.infrastructure.database.admin_repo import AdminRepositoryImpl, LoginAttemptRepositoryImpl
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.application.services.server_service import ServerService
from server.application.services.script_service import ScriptService
from server.application.services.auth_service import AuthService
from server.application.services.sync_service import SyncService
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
"""Yield DB session from middleware (request.state.db).
DbSessionMiddleware ensures every /api/ request has a session.
This dependency simply yields it so routes can use Depends(get_db).
No double-session: one session per request, shared everywhere.
"""
session = getattr(request.state, "db", None)
if session is not None:
yield session
else:
# Fallback for tests or edge cases without middleware
session = AsyncSessionLocal()
try:
yield session
finally:
await session.close()
async def _get_request_session(request: Request) -> AsyncSession:
"""Get DB session from middleware (request.state.db).
DbSessionMiddleware opens a session at the start of each API request
and stores it in request.state.db. This ensures:
1. Session is always properly closed after the request
2. All repositories/services in the same request share the same session
3. No more leaked sessions from service factories
"""
session = getattr(request.state, "db", None)
if session is None:
# Fallback: if middleware didn't set session (shouldn't happen for /api/ routes)
raise RuntimeError("DB session not found in request.state — DbSessionMiddleware may be missing")
return session
# ── Repository Factories ──
def _server_repo(session: AsyncSession) -> ServerRepositoryImpl:
return ServerRepositoryImpl(session)
def _sync_log_repo(session: AsyncSession) -> SyncLogRepositoryImpl:
return SyncLogRepositoryImpl(session)
def _script_repo(session: AsyncSession) -> ScriptRepositoryImpl:
return ScriptRepositoryImpl(session)
def _execution_repo(session: AsyncSession) -> ScriptExecutionRepositoryImpl:
return ScriptExecutionRepositoryImpl(session)
def _credential_repo(session: AsyncSession) -> DbCredentialRepositoryImpl:
return DbCredentialRepositoryImpl(session)
def _admin_repo(session: AsyncSession) -> AdminRepositoryImpl:
return AdminRepositoryImpl(session)
def _attempt_repo(session: AsyncSession) -> LoginAttemptRepositoryImpl:
return LoginAttemptRepositoryImpl(session)
def _setting_repo(session: AsyncSession) -> SettingRepositoryImpl:
return SettingRepositoryImpl(session)
def _audit_repo(session: AsyncSession) -> AuditLogRepositoryImpl:
return AuditLogRepositoryImpl(session)
def _schedule_repo(session: AsyncSession) -> PushScheduleRepositoryImpl:
return PushScheduleRepositoryImpl(session)
def _retry_repo(session: AsyncSession) -> PushRetryJobRepositoryImpl:
return PushRetryJobRepositoryImpl(session)
def _preset_repo(session: AsyncSession) -> PasswordPresetRepositoryImpl:
return PasswordPresetRepositoryImpl(session)
# ── Service Factories (D7 fixed: use request.state.db) ──
async def get_server_service(request: Request) -> ServerService:
session = await _get_request_session(request)
return ServerService(
server_repo=_server_repo(session),
sync_log_repo=_sync_log_repo(session),
)
async def get_script_service(request: Request) -> ScriptService:
session = await _get_request_session(request)
return ScriptService(
script_repo=_script_repo(session),
execution_repo=_execution_repo(session),
credential_repo=_credential_repo(session),
server_repo=_server_repo(session),
audit_repo=_audit_repo(session),
)
async def get_auth_service(request: Request) -> AuthService:
session = await _get_request_session(request)
return AuthService(
admin_repo=_admin_repo(session),
attempt_repo=_attempt_repo(session),
audit_repo=_audit_repo(session),
)
async def get_sync_service(request: Request) -> SyncService:
session = await _get_request_session(request)
return SyncService(
server_repo=_server_repo(session),
sync_log_repo=_sync_log_repo(session),
audit_repo=_audit_repo(session),
retry_repo=_retry_repo(session),
)
# ── Command Safety Detection (P2-18) ──
import re
import logging
_logger = logging.getLogger("nexus.command_safety")
# Patterns that indicate highly dangerous commands
_DANGEROUS_PATTERNS = [
(r"\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|-r[a-zA-Z]*\s+|.*--no-preserve-root\s+)/\s*$", "rm -rf / (全盘删除)"),
(r"\bdd\s+.*of=/dev/[a-z]+\s", "dd 写入裸设备 (数据毁灭)"),
(r":\(\)\{\s*:\|:&\s*\}", "Fork bomb (内存耗尽)"),
(r"mkfs\.", "格式化文件系统"),
(r">\s*/dev/sd[a-z]", "覆写磁盘设备"),
(r"chmod\s+(-R\s+)?000\s+/", "根目录权限清零"),
(r"chown\s+(-R\s+)?\S+\s+/", "根目录归属变更"),
]
_DANGEROUS_COMPILED = [(re.compile(p, re.IGNORECASE), desc) for p, desc in _DANGEROUS_PATTERNS]
def check_dangerous_command(command: str) -> list[str]:
"""Check a command for dangerous patterns.
Returns a list of warning descriptions (empty if safe).
This is a warning system, not a blocker — commands are still executed,
but dangerous ones are logged at WARNING level for audit trail.
"""
warnings = []
for pattern, desc in _DANGEROUS_COMPILED:
if pattern.search(command):
warnings.append(desc)
if warnings:
_logger.warning(f"Dangerous command detected: {command[:200]} -> {warnings}")
return warnings