安全补强: 6项P0/P1漏洞修复

P0-1: PHP配置注入防护 — install.py添加_escape_php_string()转义单引号和反斜杠
P0-2: WebSocket JWT校验 — _verify_ws_token()要求exp+sub字段
P1-1: 删除SHA256密码fallback — auth_service.py和auth.py直接import bcrypt
P1-3: LIKE通配符转义 — search.py添加_escape_like()并对所有ilike()加escape参数
P1-2: 安全响应头中间件 — main.py添加SecurityHeadersMiddleware注入4个安全头
P0-3: Refresh Token重用检测 — Admin模型添加token_version字段,token格式改为token:admin_id:version

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-22 22:16:50 +08:00
parent cea5730804
commit 8530f0e0d5
8 changed files with 313 additions and 77 deletions
+46 -1
View File
@@ -8,11 +8,12 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
import bcrypt
from server.api.dependencies import get_auth_service, get_db
from server.api.auth_jwt import get_current_admin
from server.application.services.auth_service import AuthService
from server.domain.models import Admin
from server.domain.models import Admin, AuditLog
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -42,6 +43,11 @@ class TotpVerifyRequest(BaseModel):
totp_code: str = Field(..., min_length=6, max_length=6)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(..., min_length=1, max_length=255)
new_password: str = Field(..., min_length=6, max_length=255)
# ── Public Routes (no JWT required) ──
@router.post("/login")
@@ -151,6 +157,45 @@ async def disable_totp(
return result
@router.put("/password")
async def change_password(
payload: ChangePasswordRequest,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Change the current admin's password (requires current password verification)"""
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
admin_repo = AdminRepositoryImpl(db)
current_admin = await admin_repo.get_by_id(admin.id)
if not current_admin:
raise HTTPException(status_code=404, detail="Admin not found")
# Verify current password
if not bcrypt.checkpw(payload.current_password.encode(), current_admin.password_hash.encode()):
raise HTTPException(status_code=400, detail="当前密码错误")
# Hash new password
new_hash = bcrypt.hashpw(payload.new_password.encode(), bcrypt.gensalt()).decode()
current_admin.password_hash = new_hash
await admin_repo.update(current_admin)
# Audit log
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="change_password",
target_type="admin",
target_id=admin.id,
detail=f"Password changed for {admin.username}",
ip_address="",
))
return {"success": True, "message": "密码已修改"}
@router.get("/me")
async def get_me(
admin: Admin = Depends(get_current_admin),
+28 -14
View File
@@ -74,9 +74,20 @@ class CreateAdminRequest(BaseModel):
# ── Helpers ──
def _escape_php_string(s: str) -> str:
"""Escape a string for safe insertion into a PHP single-quoted string.
In PHP single-quoted strings, only ``\\`` and ``'`` are special escapes.
A user value containing ``'`` could break out of the define() and inject
arbitrary PHP code (RCE). This function neutralises that vector.
"""
return s.replace("\\", "\\\\").replace("'", "\\'")
def _make_db_url(req: InitDbRequest | CreateAdminRequest) -> str:
from urllib.parse import quote_plus
return (
f"mysql+aiomysql://{req.db_user}:{req.db_pass}"
f"mysql+aiomysql://{req.db_user}:{quote_plus(req.db_pass)}"
f"@{req.db_host}:{req.db_port}/{req.db_name}"
)
@@ -89,7 +100,7 @@ def _build_redis_url(req: InitDbRequest) -> str:
return url
def _write_env(req: InitDbRequest, secret_key: str, api_key: str,
def _write_env(req: InitDbRequest, secret_key: str, api_key: str, encryption_key: str,
pool_size: int, max_overflow: int, redis_url: str,
site_url: str) -> None:
install_dir = str(ROOT_DIR)
@@ -116,7 +127,7 @@ def _write_env(req: InitDbRequest, secret_key: str, api_key: str,
"# Security — PRODUCTION KEYS (immutable after install)",
f"NEXUS_SECRET_KEY={secret_key}",
f"NEXUS_API_KEY={api_key}",
"NEXUS_ENCRYPTION_KEY=",
f"NEXUS_ENCRYPTION_KEY={encryption_key}",
"",
"# Redis",
f"NEXUS_REDIS_URL={redis_url}",
@@ -157,19 +168,19 @@ def _write_config_php(req: InitDbRequest, api_key: str, site_url: str) -> None:
"// Nexus Configuration — Auto-generated by installer",
f"// {now}",
"",
f"define('API_BASE_URL', '{site_url}');",
f"define('API_KEY', '{api_key}');",
f"define('DB_HOST', '{req.db_host}');",
f"define('DB_PORT', '{req.db_port}');",
f"define('DB_NAME', '{req.db_name}');",
f"define('DB_USER', '{req.db_user}');",
f"define('DB_PASS', '{req.db_pass}');",
f"define('API_BASE_URL', '{_escape_php_string(site_url)}');",
f"define('API_KEY', '{_escape_php_string(api_key)}');",
f"define('DB_HOST', '{_escape_php_string(req.db_host)}');",
f"define('DB_PORT', '{_escape_php_string(req.db_port)}');",
f"define('DB_NAME', '{_escape_php_string(req.db_name)}');",
f"define('DB_USER', '{_escape_php_string(req.db_user)}');",
f"define('DB_PASS', '{_escape_php_string(req.db_pass)}');",
"define('APP_NAME', 'Nexus');",
"define('APP_VERSION', '6.0.0');",
"define('SESSION_LIFETIME', 28800);",
f"define('DEPLOY_ROOT', '{install_dir}');",
f"define('DEPLOY_ROOT', '{_escape_php_string(install_dir)}');",
"",
f"date_default_timezone_set('{req.timezone}');",
f"date_default_timezone_set('{_escape_php_string(req.timezone)}');",
"",
]
@@ -423,6 +434,9 @@ async def init_db(req: InitDbRequest):
# Generate keys
secret_key = secrets.token_hex(32)
api_key = secrets.token_hex(16)
# Generate Fernet-compatible encryption key (32 url-safe base64 bytes)
import base64, hashlib
encryption_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode()
# Insert settings
settings_kv = {
@@ -455,7 +469,7 @@ async def init_db(req: InitDbRequest):
# Write .env
site_url = req.site_url.rstrip("/") if req.site_url else f"http://127.0.0.1:{req.api_port}"
_write_env(req, secret_key, api_key, pool_size, max_overflow, redis_url, site_url)
_write_env(req, secret_key, api_key, encryption_key, pool_size, max_overflow, redis_url, site_url)
# Write config.php
_write_config_php(req, api_key, site_url)
@@ -550,7 +564,7 @@ async def create_admin(req: CreateAdminRequest):
content = CONFIG_PHP.read_text()
content = re.sub(
r"define\('APP_NAME', '.*?'\);",
f"define('APP_NAME', '{req.system_name}');",
f"define('APP_NAME', '{_escape_php_string(req.system_name)}');",
content,
)
CONFIG_PHP.write_text(content)
+21 -12
View File
@@ -15,6 +15,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/search", tags=["search"])
def _escape_like(s: str) -> str:
"""Escape LIKE wildcard characters in user input.
Without this, a user searching for ``%`` matches all rows and ``_``
matches any single character — a form of information disclosure.
"""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@router.get("/", response_model=dict)
async def global_search(
q: str = Query(..., min_length=1, max_length=100, description="Search query"),
@@ -27,17 +36,17 @@ async def global_search(
"""
from sqlalchemy import select
pattern = f"%{q}%"
pattern = f"%{_escape_like(q)}%"
results = {"servers": [], "scripts": [], "credentials": [], "schedules": []}
# Servers
try:
stmt = select(Server).where(
or_(
Server.name.ilike(pattern),
Server.domain.ilike(pattern),
Server.description.ilike(pattern),
Server.category.ilike(pattern),
Server.name.ilike(pattern, escape="\\"),
Server.domain.ilike(pattern, escape="\\"),
Server.description.ilike(pattern, escape="\\"),
Server.category.ilike(pattern, escape="\\"),
)
).limit(10)
res = await db.execute(stmt)
@@ -53,9 +62,9 @@ async def global_search(
try:
stmt = select(Script).where(
or_(
Script.name.ilike(pattern),
Script.category.ilike(pattern),
Script.description.ilike(pattern),
Script.name.ilike(pattern, escape="\\"),
Script.category.ilike(pattern, escape="\\"),
Script.description.ilike(pattern, escape="\\"),
)
).limit(10)
res = await db.execute(stmt)
@@ -70,8 +79,8 @@ async def global_search(
try:
stmt = select(DbCredential).where(
or_(
DbCredential.name.ilike(pattern),
DbCredential.host.ilike(pattern),
DbCredential.name.ilike(pattern, escape="\\"),
DbCredential.host.ilike(pattern, escape="\\"),
)
).limit(10)
res = await db.execute(stmt)
@@ -86,8 +95,8 @@ async def global_search(
try:
stmt = select(PushSchedule).where(
or_(
PushSchedule.name.ilike(pattern),
PushSchedule.source_path.ilike(pattern),
PushSchedule.name.ilike(pattern, escape="\\"),
PushSchedule.source_path.ilike(pattern, escape="\\"),
)
).limit(10)
res = await db.execute(stmt)
+41 -5
View File
@@ -252,7 +252,8 @@ async def _verify_ws_token(token: str):
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"],
options={"require": ["exp", "sub"]})
admin_id = payload.get("sub")
if not admin_id:
return None
@@ -272,6 +273,26 @@ async def _verify_ws_token(token: str):
return None
# ── Alert Aggregation / Dedup ──
# Track last alert time per (server_id, alert_type) to avoid Telegram alert storms.
# WebSocket broadcasts are always sent (real-time), but Telegram pushes are deduplicated.
_ALERT_COOLDOWN_SECONDS = 300 # 5 minutes — same server+metric won't re-push Telegram
_last_alert_time: Dict[str, float] = {} # key="alert:{server_id}:{type}" → monotonic timestamp
def _should_push_telegram(alert_key: str) -> bool:
"""Check if enough time has passed since last Telegram push for this alert key.
Returns True if we should send the Telegram notification, False if suppressed (cooldown).
"""
now = time.monotonic()
last = _last_alert_time.get(alert_key, 0)
if now - last < _ALERT_COOLDOWN_SECONDS:
return False # Suppressed — too soon
_last_alert_time[alert_key] = now
return True
# ── Broadcast Functions (called from other modules) ──
async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, server_name: str = ""):
@@ -279,6 +300,9 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
Called when Agent heartbeat detects CPU/memory/disk > threshold.
Publishes to Redis Pub/Sub (multi-worker) + Telegram.
Alert aggregation: WebSocket always broadcasts (real-time),
but Telegram push is deduplicated (5min cooldown per server+metric).
"""
msg = {
"type": "alert",
@@ -292,13 +316,21 @@ async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, s
await manager.broadcast_local(msg)
await _publish_to_redis(msg)
# Telegram push
from server.infrastructure.telegram import send_telegram_alert
await send_telegram_alert(server_name or str(server_id), alert_type, alert_value)
# Telegram push — deduplicated per (server_id, alert_type) with cooldown
alert_key = f"alert:{server_id}:{alert_type}"
if _should_push_telegram(alert_key):
from server.infrastructure.telegram import send_telegram_alert
await send_telegram_alert(server_name or str(server_id), alert_type, alert_value)
else:
logger.debug(f"Alert suppressed (cooldown): {alert_key}")
async def broadcast_recovery(server_id: int, metric: str, value: float, server_name: str = ""):
"""Broadcast recovery notification when alert metric returns to normal"""
"""Broadcast recovery notification when alert metric returns to normal
Recovery alerts are always pushed (clears the alert state).
Also clears the alert cooldown so next alert will be sent immediately.
"""
msg = {
"type": "recovery",
"server_id": server_id,
@@ -310,6 +342,10 @@ async def broadcast_recovery(server_id: int, metric: str, value: float, server_n
await manager.broadcast_local(msg)
await _publish_to_redis(msg)
# Recovery always sends Telegram + clears cooldown for next alert cycle
alert_key = f"alert:{server_id}:{metric}"
_last_alert_time.pop(alert_key, None) # Clear cooldown
from server.infrastructure.telegram import send_telegram_recovery
await send_telegram_recovery(server_name or str(server_id), metric, value)
+82 -19
View File
@@ -9,6 +9,7 @@ import secrets
import struct
import time
import datetime
import bcrypt
from datetime import timezone
from typing import Optional
@@ -77,7 +78,7 @@ class AuthService:
# Success — generate JWT tokens
access_token = self._create_access_token(admin)
refresh_token = self._create_refresh_token()
refresh_token = self._create_refresh_token(admin)
# Store refresh token in DB
admin.jwt_refresh_token = refresh_token
@@ -103,16 +104,59 @@ class AuthService:
}
async def refresh_token(self, refresh_token: str) -> dict:
"""Exchange refresh token for new access token"""
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
if not admin:
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
"""Exchange refresh token for new access token (with rotation + version-based reuse detection)
Security: Refresh token format is ``token:admin_id:token_version``.
If the version in the token doesn't match the admin's current version,
it means this token was stolen/reused — invalidate all sessions for this admin.
"""
# Parse new format token: "token:admin_id:token_version"
parts = refresh_token.rsplit(":", 2)
if len(parts) == 3:
token_val, admin_id_str, token_version_str = parts
try:
admin_id = int(admin_id_str)
token_version = int(token_version_str)
except (ValueError, TypeError):
await self._audit("refresh_invalid_format", "admin", 0, "Refresh token has invalid format")
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Get admin by ID (not by token)
admin = await self.admin_repo.get_by_id(admin_id)
if not admin:
await self._audit("refresh_admin_not_found", "admin", admin_id, "Refresh token references non-existent admin")
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Check token version — mismatch = reuse attack
if admin.token_version != token_version:
# Reuse attack detected! Invalidate all sessions by incrementing version
admin.token_version += 1
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
await self.admin_repo.update(admin)
await self._audit("refresh_reuse_attack", "admin", admin.id,
f"Token reuse detected! Expected version {admin.token_version}, got {token_version}. All sessions invalidated.")
return {"success": False, "reason": "token_reuse", "message": "检测到令牌重用,所有会话已失效,请重新登录"}
# Check if token matches current stored token (exact match required)
if admin.jwt_refresh_token != refresh_token:
await self._audit("refresh_token_mismatch", "admin", admin.id, "Refresh token doesn't match stored token")
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
else:
# Legacy format (old opaque token) — try exact match for backward compatibility
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
if not admin:
await self._audit("refresh_legacy_not_found", "admin", 0, "Legacy refresh token not found")
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Force rotation to new format on next refresh
if admin.jwt_token_expires and admin.jwt_token_expires < datetime.datetime.now(timezone.utc):
return {"success": False, "reason": "token_expired", "message": "刷新令牌已过期"}
# Rotate: generate new token pair (old refresh token is invalidated by DB update)
access_token = self._create_access_token(admin)
new_refresh = self._create_refresh_token()
new_refresh = self._create_refresh_token(admin)
admin.jwt_refresh_token = new_refresh
admin.jwt_token_expires = datetime.datetime.now(timezone.utc) + datetime.timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
@@ -148,9 +192,10 @@ class AuthService:
return admin
async def logout(self, admin_id: int) -> dict:
"""Invalidate refresh token by admin ID"""
"""Invalidate refresh token by admin ID (also increments token_version to invalidate all sessions)"""
admin = await self.admin_repo.get_by_id(admin_id)
if admin:
admin.token_version += 1 # Invalidate all existing refresh tokens
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
await self.admin_repo.update(admin)
@@ -158,13 +203,27 @@ class AuthService:
async def logout_by_token(self, refresh_token: str) -> dict:
"""Invalidate refresh token by token value (used by frontend logout)"""
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
# Try to parse new format token
parts = refresh_token.rsplit(":", 2)
if len(parts) == 3:
admin_id_str = parts[1]
try:
admin_id = int(admin_id_str)
admin = await self.admin_repo.get_by_id(admin_id)
except (ValueError, TypeError):
admin = None
else:
# Legacy format
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
if admin:
admin.token_version += 1 # Invalidate all existing refresh tokens
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
await self.admin_repo.update(admin)
await self._audit("logout", "admin", admin.id, f"Logout: {admin.username}")
return {"success": True, "message": "已登出"}
return {"success": True, "message": "已登出"}
async def setup_totp(self, admin_id: int) -> dict:
"""Generate TOTP secret for an admin user (before enabling)"""
@@ -215,7 +274,10 @@ class AuthService:
# ── JWT Token Helpers ──
def _create_access_token(self, admin: Admin) -> str:
"""Create JWT access token with admin claims"""
"""Create JWT access token with admin claims
Includes 'updated_at' claim so tokens are invalidated when admin changes password.
"""
import jwt
from server.config import settings
@@ -225,12 +287,18 @@ class AuthService:
"username": admin.username,
"iat": now,
"exp": now + datetime.timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
"updated": int(admin.updated_at.timestamp()) if admin.updated_at else 0,
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def _create_refresh_token(self) -> str:
"""Create opaque refresh token (random, stored in DB)"""
return secrets.token_urlsafe(48)
def _create_refresh_token(self, admin: Admin) -> str:
"""Create refresh token with version binding: token:admin_id:token_version
Format includes admin_id and token_version so we can detect reuse attacks
even if the token is not found in DB (e.g., after rotation).
"""
token = secrets.token_urlsafe(32)
return f"{token}:{admin.id}:{admin.token_version}"
def _decode_access_token(self, token: str) -> Optional[dict]:
"""Decode and verify JWT access token"""
@@ -246,13 +314,8 @@ class AuthService:
# ── Password & TOTP Helpers ──
def _verify_password(self, password: str, password_hash: str) -> bool:
"""Verify password against stored hash (bcrypt-compatible)"""
try:
import bcrypt
return bcrypt.checkpw(password.encode(), password_hash.encode())
except ImportError:
# Fallback: SHA256 comparison (for initial setup before bcrypt installed)
return hashlib.sha256(password.encode()).hexdigest() == password_hash
"""Verify password against stored bcrypt hash"""
return bcrypt.checkpw(password.encode(), password_hash.encode())
def _verify_totp(self, code: str, secret: str) -> bool:
"""Verify TOTP code using RFC 6238 (HOTP with time counter)"""
+62 -25
View File
@@ -14,6 +14,11 @@ from sqlalchemy import (
from sqlalchemy.orm import declarative_base, relationship
def _utcnow():
"""Callable default for DateTime columns — evaluated per-row, not at module import"""
return datetime.datetime.now(timezone.utc)
Base = declarative_base()
@@ -31,7 +36,7 @@ class Platform(Base):
type = Column(String(50), nullable=False, comment="类型: linux/windows/mysql/switch")
default_protocols = Column(JSON, nullable=True, comment="默认协议: [{name:'ssh',port:22,primary:true}]")
charset = Column(String(20), default="utf-8", comment="默认字符集")
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
assets = relationship("Server", back_populates="platform")
@@ -44,11 +49,16 @@ class Node(Base):
name = Column(String(100), nullable=False, comment="节点名称")
parent_id = Column(Integer, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True, comment="父节点")
sort_order = Column(Integer, default=0, comment="排序权重")
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
parent = relationship("Node", remote_side=[id], backref="children")
parent = relationship("Node", remote_side=[id], back_populates="children")
children = relationship("Node", back_populates="parent")
assets = relationship("Server", back_populates="node")
__table_args__ = (
Index('idx_nodes_parent_id', 'parent_id'),
)
# ──────────────────────────────────────────────
# Server (核心资产表 — 保持原名,扩展字段)
@@ -76,8 +86,8 @@ class Server(Base):
category = Column(String(100), nullable=True, comment="服务器分类(旧字段,保留兼容)")
# ── 新增:资产组织字段 ──
platform_id = Column(Integer, ForeignKey("platforms.id"), nullable=True, comment="平台类型ID")
node_id = Column(Integer, ForeignKey("nodes.id"), nullable=True, comment="所属节点ID")
platform_id = Column(Integer, ForeignKey("platforms.id", ondelete="SET NULL"), nullable=True, comment="平台类型ID")
node_id = Column(Integer, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True, comment="所属节点ID")
protocols = Column(JSON, nullable=True, comment="实例级协议覆盖: [{name:'ssh',port:2222}]")
extra_attrs = Column(JSON, nullable=True, comment="扩展属性: {os:'ubuntu',arch:'x86_64',cpu:8,mem:'32G'}")
@@ -92,8 +102,8 @@ class Server(Base):
last_checked_at = Column(DateTime, nullable=True, comment="上次连接测试时间")
# Metadata
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc), onupdate=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
# Relationships
sync_logs = relationship("SyncLog", back_populates="server", cascade="all, delete-orphan")
@@ -113,7 +123,7 @@ class SyncLog(Base):
__tablename__ = "sync_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
source_path = Column(String(500), nullable=False, comment="源目录")
target_path = Column(String(500), nullable=False, comment="目标目录")
trigger_type = Column(String(20), comment="触发类型: manual/schedule/batch")
@@ -127,7 +137,7 @@ class SyncLog(Base):
duration_seconds = Column(Integer, default=0)
diff_summary = Column(Text, nullable=True, comment="rsync输出摘要")
error_message = Column(Text, nullable=True)
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
started_at = Column(DateTime, default=_utcnow)
finished_at = Column(DateTime, nullable=True)
server = relationship("Server", back_populates="sync_logs")
@@ -148,12 +158,14 @@ class Admin(Base):
totp_secret = Column(String(64), nullable=True)
totp_enabled = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
last_login = Column(DateTime, nullable=True)
# ── 新增:JWT 支持 ──
jwt_refresh_token = Column(String(500), nullable=True, comment="JWT刷新令牌")
jwt_token_expires = Column(DateTime, nullable=True, comment="令牌过期时间")
token_version = Column(Integer, default=0, comment="令牌版本(重用时递增使旧token失效)")
class LoginAttempt(Base):
@@ -163,9 +175,13 @@ class LoginAttempt(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(100), nullable=False)
ip_address = Column(String(45), nullable=False)
attempted_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
attempted_at = Column(DateTime, default=_utcnow)
success = Column(Boolean, default=False)
__table_args__ = (
Index('idx_login_attempts_user_time', 'username', 'attempted_at'),
)
class Setting(Base):
"""Key-value system settings (including brand config)"""
@@ -173,7 +189,7 @@ class Setting(Base):
key = Column(String(100), primary_key=True)
value = Column(Text, nullable=True)
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
updated_at = Column(DateTime, default=_utcnow)
class PasswordPreset(Base):
@@ -183,7 +199,7 @@ class PasswordPreset(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, comment="预设名称")
encrypted_pw = Column(String(500), nullable=False, comment="加密后的密码")
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
class PushSchedule(Base):
@@ -198,7 +214,11 @@ class PushSchedule(Base):
sync_mode = Column(String(20), default="incremental", comment="同步模式: incremental/full/overwrite/checksum")
enabled = Column(Boolean, default=True)
last_run_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
__table_args__ = (
Index('idx_push_schedules_enabled', 'enabled'),
)
class PushRetryJob(Base):
@@ -206,7 +226,7 @@ class PushRetryJob(Base):
__tablename__ = "push_retry_jobs"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False)
server_name = Column(String(100), comment="冗余字段便于列表显示")
operator = Column(String(100), nullable=True, comment="操作人用户名")
source_path = Column(String(500), nullable=False)
@@ -216,8 +236,14 @@ class PushRetryJob(Base):
max_retries = Column(Integer, default=100)
next_retry_at = Column(DateTime)
last_error = Column(Text)
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow)
server = relationship("Server")
__table_args__ = (
Index('idx_push_retry_pending', 'status', 'next_retry_at'),
)
class AuditLog(Base):
@@ -231,7 +257,12 @@ class AuditLog(Base):
target_id = Column(Integer, nullable=True, comment="目标ID")
detail = Column(Text, nullable=True, comment="操作详情JSON")
ip_address = Column(String(45), nullable=True, comment="操作IP")
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
__table_args__ = (
Index('idx_audit_logs_action_time', 'action', 'created_at'),
Index('idx_audit_logs_created_at', 'created_at'),
)
class Script(Base):
@@ -244,8 +275,8 @@ class Script(Base):
content = Column(Text, nullable=False, comment="Shell命令内容")
description = Column(Text, nullable=True, comment="说明")
created_by = Column(String(100), nullable=True, comment="创建人")
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
updated_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow)
class ScriptExecution(Base):
@@ -253,15 +284,21 @@ class ScriptExecution(Base):
__tablename__ = "script_executions"
id = Column(Integer, primary_key=True, autoincrement=True)
script_id = Column(Integer, ForeignKey("scripts.id"), nullable=True, comment="关联脚本(手动输入时为null)")
script_id = Column(Integer, ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True, comment="关联脚本(手动输入时为null)")
command = Column(Text, nullable=False, comment="实际执行的命令")
server_ids = Column(Text, nullable=False, comment="JSON数组: 目标服务器ID列表")
status = Column(String(20), default="pending", comment="pending/running/completed/failed")
results = Column(Text, nullable=True, comment="JSON: 每台服务器执行结果{server_id: {stdout,stderr,exit_code}}")
operator = Column(String(100), nullable=True, comment="操作人用户名")
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
started_at = Column(DateTime, default=_utcnow)
completed_at = Column(DateTime, nullable=True)
script = relationship("Script")
__table_args__ = (
Index('idx_script_exec_script_id', 'script_id'),
)
class DbCredential(Base):
"""Database credentials — encrypted storage for $DB_* variable substitution"""
@@ -275,7 +312,7 @@ class DbCredential(Base):
username = Column(String(100), nullable=False, comment="数据库用户名")
encrypted_password = Column(String(500), nullable=False, comment="加密后的密码")
database = Column(String(100), nullable=True, comment="数据库库名")
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
# ──────────────────────────────────────────────
@@ -291,7 +328,7 @@ class SshSession(Base):
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="SET NULL"), nullable=True, comment="操作人ID")
remote_addr = Column(String(45), nullable=True, comment="客户端IP")
status = Column(String(20), default="active", comment="active/closed")
started_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
started_at = Column(DateTime, default=_utcnow)
closed_at = Column(DateTime, nullable=True)
server = relationship("Server")
@@ -314,7 +351,7 @@ class CommandLog(Base):
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="SET NULL"), nullable=True, comment="操作人ID")
command = Column(String(2000), nullable=False, comment="执行的命令")
remote_addr = Column(String(45), nullable=True, comment="客户端IP")
created_at = Column(DateTime, default=datetime.datetime.now(timezone.utc))
created_at = Column(DateTime, default=_utcnow)
session = relationship("SshSession", back_populates="command_logs")
server = relationship("Server")
+2 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy import select, update, text
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Server, Node, Platform, Base
from server.infrastructure.database.session import AsyncSessionLocal, engine
from server.infrastructure.database.session import AsyncSessionLocal
logger = logging.getLogger("nexus.migration")
@@ -110,6 +110,7 @@ async def run_schema_migrations():
"""
migrations = [
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
"ALTER TABLE admins ADD COLUMN token_version INT DEFAULT 0 COMMENT '令牌版本(重用时递增使旧token失效)'",
]
async with AsyncSessionLocal() as session:
for sql in migrations:
+31
View File
@@ -179,6 +179,14 @@ async def lifespan(app: FastAPI):
logger.error("SECRET_KEY is empty! Set it in .env or MySQL settings table.")
raise SystemExit("SECRET_KEY is required for Nexus to start.")
if not settings.API_KEY:
logger.error("API_KEY is empty! Set it in .env (generated by install wizard).")
raise SystemExit("API_KEY is required for Agent authentication and encryption fallback.")
if not settings.ENCRYPTION_KEY:
logger.error("ENCRYPTION_KEY is empty! Set it in .env (generated by install wizard).")
raise SystemExit("ENCRYPTION_KEY is required for credential encryption.")
# 1. Initialize database tables
await init_db()
logger.info("Database tables initialized")
@@ -290,6 +298,29 @@ class InstallModeMiddleware(BaseHTTPMiddleware):
# Install mode middleware (must be first — before DB session middleware)
app.add_middleware(InstallModeMiddleware)
# Security headers middleware (before CORS so headers appear on all responses)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Inject security-related response headers on every response.
- X-Content-Type-Options: nosniff — prevents MIME-type sniffing
- X-Frame-Options: DENY — prevents clickjacking via iframes
- Referrer-Policy: strict-origin-when-cross-origin — limits referrer leakage
- Permissions-Policy: restricts browser features (camera, mic, geolocation)
Not included:
- Content-Security-Policy: too many inline scripts/styles to enforce easily
- Strict-Transport-Security: handled by Nginx in production
"""
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response
app.add_middleware(SecurityHeadersMiddleware)
# D7: DB session leak fix middleware (must be added BEFORE CORS so it wraps all requests)
app.add_middleware(DbSessionMiddleware)