"""Nexus — Domain Models SQLAlchemy ORM models for all database tables. """ import datetime from datetime import timezone import uuid from sqlalchemy import ( Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Index, JSON ) 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() # ────────────────────────────────────────────── # Platform & Node (资产组织层) # ────────────────────────────────────────────── class Platform(Base): """Asset type template — defines what kind of asset this is""" __tablename__ = "platforms" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), unique=True, nullable=False, comment="平台名称: Linux服务器/Windows/MySQL") category = Column(String(50), nullable=False, comment="分类: host/database/device") 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=_utcnow) assets = relationship("Server", back_populates="platform") class Node(Base): """Tree-structured asset grouping""" __tablename__ = "nodes" id = Column(Integer, primary_key=True, autoincrement=True) 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=_utcnow) 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 (核心资产表 — 保持原名,扩展字段) # ────────────────────────────────────────────── class Server(Base): """Sub-server configuration / Asset""" __tablename__ = "servers" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), unique=True, nullable=False, comment="服务器名称") domain = Column(String(255), nullable=False, comment="域名或IP") port = Column(Integer, default=22, comment="SSH端口") username = Column(String(100), default="root", comment="SSH用户名") auth_method = Column(String(20), default="key", comment="认证方式: key/password") password = Column(String(255), nullable=True, comment="SSH密码(加密存储)") ssh_key_path = Column(String(500), nullable=True, comment="SSH私钥路径") # Text columns: RSA 4096 private key PEM is ~3.2 KB; Fernet-encrypted ~4.3 KB. # String(500) would cause DataError in MySQL strict mode for large keys. ssh_key_private = Column(Text, nullable=True, comment="加密后的私钥内容(Fernet)") ssh_key_public = Column(Text, nullable=True, comment="公钥内容") ssh_key_configured = Column(Boolean, default=False, comment="是否已配置SSH Key") agent_port = Column(Integer, default=8601, comment="Agent API端口") agent_api_key = Column(String(255), nullable=True, comment="Agent API密钥") preset_id = Column(Integer, nullable=True, comment="关联的密码预设ID(SSH认证)") ssh_key_preset_id = Column(Integer, nullable=True, comment="关联的密钥预设ID(SSH认证)") description = Column(Text, nullable=True, comment="备注说明") target_path = Column(String(500), nullable=True, comment="推送目标路径") category = Column(String(100), nullable=True, comment="服务器分类(旧字段,保留兼容)") # ── 新增:资产组织字段 ── 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'}") # Status (from Agent heartbeat) is_online = Column(Boolean, default=False, comment="是否在线") last_heartbeat = Column(DateTime, nullable=True, comment="最后心跳时间") system_info = Column(Text, nullable=True, comment="系统信息JSON(Agent上报)") agent_version = Column(String(20), nullable=True, comment="Agent版本") # ── 新增:连接状态 ── connectivity = Column(String(20), default="unknown", comment="连接状态: ok/err/unknown") last_checked_at = Column(DateTime, nullable=True, comment="上次连接测试时间") # Metadata 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") platform = relationship("Platform", back_populates="assets") node = relationship("Node", back_populates="assets") __table_args__ = ( Index('idx_servers_is_online', 'is_online'), Index('idx_servers_category', 'category'), Index('idx_servers_platform_id', 'platform_id'), Index('idx_servers_node_id', 'node_id'), ) class SyncLog(Base): """Push operation log""" __tablename__ = "sync_logs" id = Column(Integer, primary_key=True, autoincrement=True) 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") operator = Column(String(100), nullable=True, comment="操作人用户名") status = Column(String(20), comment="pending/running/success/failed") sync_mode = Column(String(20), default="incremental", comment="同步模式: incremental/full/overwrite/checksum") files_total = Column(Integer, default=0) files_transferred = Column(Integer, default=0) files_skipped = Column(Integer, default=0) bytes_transferred = Column(Integer, default=0) duration_seconds = Column(Integer, default=0) diff_summary = Column(Text, nullable=True, comment="rsync输出摘要") push_permission = Column(String(120), nullable=True, comment="推送后权限策略 chown=… chmod=…") error_message = Column(Text, nullable=True) started_at = Column(DateTime, default=_utcnow) finished_at = Column(DateTime, nullable=True) server = relationship("Server", back_populates="sync_logs") __table_args__ = ( Index('idx_sync_logs_srv_start', 'server_id', 'started_at'), ) class Admin(Base): """Admin user for web login""" __tablename__ = "admins" id = Column(Integer, primary_key=True, autoincrement=True) username = Column(String(100), unique=True, nullable=False) password_hash = Column(String(255), nullable=False) email = Column(String(255), nullable=True) totp_secret = Column(String(64), nullable=True) totp_enabled = Column(Boolean, default=False) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=_utcnow) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) last_login = Column(DateTime, nullable=True) token_version = Column(Integer, default=0, comment="令牌版本(重用时递增使旧token失效)") class LoginAttempt(Base): """Login attempt log for brute-force protection""" __tablename__ = "login_attempts" 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=_utcnow) success = Column(Boolean, default=False) __table_args__ = ( Index('idx_login_attempts_user_time', 'username', 'attempted_at'), ) class AdminRefreshToken(Base): """Refresh token hash persisted for login sessions (Redis + MySQL dual store).""" __tablename__ = "admin_refresh_tokens" id = Column(Integer, primary_key=True, autoincrement=True) admin_id = Column(Integer, nullable=False, comment="管理员 ID") token_hash = Column(String(64), nullable=False, unique=True, comment="SHA-256(refresh token)") expires_at = Column(DateTime, nullable=False, comment="过期时间 UTC") created_at = Column(DateTime, default=_utcnow) __table_args__ = ( Index("idx_admin_refresh_admin_expires", "admin_id", "expires_at"), ) class Setting(Base): """Key-value system settings (including brand config)""" __tablename__ = "settings" key = Column(String(100), primary_key=True) value = Column(Text, nullable=True) updated_at = Column(DateTime, default=_utcnow) class PasswordPreset(Base): """SSH password preset — encrypted storage""" __tablename__ = "password_presets" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), nullable=False, comment="预设名称") username = Column(String(100), default="root", comment="SSH用户名") encrypted_pw = Column(String(500), nullable=False, comment="加密后的密码") created_at = Column(DateTime, default=_utcnow) class SshKeyPreset(Base): """SSH key preset — encrypted private key storage""" __tablename__ = "ssh_key_presets" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), nullable=False, comment="预设名称") username = Column(String(100), default="root", comment="SSH用户名") encrypted_private_key = Column(Text, nullable=False, comment="加密后的私钥内容(Fernet)") public_key = Column(Text, nullable=True, comment="公钥内容(不加密)") created_at = Column(DateTime, default=_utcnow) class PendingServer(Base): """Servers awaiting successful SSH credential polling before promotion to servers table.""" __tablename__ = "pending_servers" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), nullable=False, comment="显示名称") domain = Column(String(255), nullable=False, comment="IP或域名") port = Column(Integer, default=22, comment="SSH端口") agent_port = Column(Integer, default=8601, comment="Agent端口") target_path = Column(String(500), nullable=True, comment="推送目标路径") category = Column(String(100), nullable=True, comment="分类") platform_id = Column(Integer, nullable=True, comment="平台ID") node_id = Column(Integer, nullable=True, comment="节点ID") attempts = Column(Integer, default=0, comment="轮询尝试次数") last_error = Column(Text, nullable=True, comment="最后一次失败摘要") last_attempt_at = Column(DateTime, nullable=True, comment="最后尝试时间") created_at = Column(DateTime, default=_utcnow) __table_args__ = ( Index("idx_pending_servers_domain", "domain"), ) class PushSchedule(Base): """Scheduled task — cron-triggered file push OR script execution.""" __tablename__ = "push_schedules" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), nullable=False, comment="调度名称") # ── Schedule type ── # 'push': rsync file sync (default, original behaviour) # 'script': execute a shell command / script via Agent schedule_type = Column(String(20), default="push", comment="调度类型: push | script") # ── Push-specific fields (schedule_type = 'push') ── source_path = Column(String(500), nullable=True, comment="推送源目录(push 类型必填)") target_path = Column(String(500), nullable=True, comment="推送目标路径(留空则各机 target_path)") sync_mode = Column(String(20), default="incremental", comment="同步模式: incremental/full/overwrite/checksum") # ── Script-specific fields (schedule_type = 'script') ── script_id = Column(Integer, ForeignKey("scripts.id", ondelete="SET NULL"), nullable=True, comment="脚本库引用(script 类型,可留空使用 script_content)") script_content = Column(Text, nullable=True, comment="临时命令内容(script 类型,不保存到脚本库)") exec_timeout = Column(Integer, default=60, comment="执行超时(秒)") long_task = Column(Boolean, default=False, comment="长任务 nohup 模式") # ── Common fields ── server_ids = Column(Text, nullable=False, comment="JSON数组: 目标服务器ID列表") run_mode = Column(String(10), default="once", comment="运行模式: once(一次性) | cron(循环)") cron_expr = Column(String(100), nullable=True, comment="cron表达式 (run_mode=cron时必填)") fire_at = Column(DateTime, nullable=True, comment="一次性执行时间 (run_mode=once时必填)") enabled = Column(Boolean, default=True) last_run_at = Column(DateTime, nullable=True) created_at = Column(DateTime, default=_utcnow) __table_args__ = ( Index('idx_push_schedules_enabled', 'enabled'), ) class PushRetryJob(Base): """Failed push retry queue""" __tablename__ = "push_retry_jobs" id = Column(Integer, primary_key=True, autoincrement=True) 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) target_path = Column(String(500), nullable=False) status = Column(String(20), default="pending") retry_count = Column(Integer, default=0) max_retries = Column(Integer, default=3) next_retry_at = Column(DateTime) last_error = Column(Text) 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): """Audit log — record all key operations""" __tablename__ = "audit_logs" id = Column(Integer, primary_key=True, autoincrement=True) admin_username = Column(String(100), nullable=True, comment="操作人用户名") action = Column(String(50), nullable=False, comment="操作类型") target_type = Column(String(50), nullable=True, comment="目标类型") 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=_utcnow) __table_args__ = ( Index('idx_audit_logs_action_time', 'action', 'created_at'), Index('idx_audit_logs_created_at', 'created_at'), ) class Script(Base): """Script library — reusable shell scripts""" __tablename__ = "scripts" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), nullable=False, comment="脚本名称") category = Column(String(50), default="ops", comment="分类: ops/deploy/check/cleanup") 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=_utcnow) updated_at = Column(DateTime, default=_utcnow) class ScriptExecution(Base): """Script execution log — results per server""" __tablename__ = "script_executions" id = Column(Integer, primary_key=True, autoincrement=True) 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列表") credential_id = Column(Integer, ForeignKey("db_credentials.id", ondelete="SET NULL"), nullable=True, comment="DB凭据替换$DB_*变量") 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=_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""" __tablename__ = "db_credentials" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), nullable=False, comment="凭据名称") db_type = Column(String(20), default="mysql", comment="数据库类型: mysql/postgresql/etc") host = Column(String(255), default="localhost", comment="数据库主机") port = Column(Integer, default=3306, comment="数据库端口") 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=_utcnow) # ────────────────────────────────────────────── # Web SSH 会话 & 命令日志 # ────────────────────────────────────────────── class AlertLog(Base): """Persistent alert & recovery history.""" __tablename__ = "alert_logs" id = Column(Integer, primary_key=True, autoincrement=True) server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False) server_name = Column(String(100), nullable=True, comment="冗余服务器名,便于列表显示") alert_type = Column(String(20), nullable=False, comment="cpu|mem|disk|time_drift|system") value = Column(String(50), nullable=True, comment="指标值(百分比或描述)") is_recovery = Column(Boolean, default=False, comment="True=恢复通知") created_at = Column(DateTime, default=_utcnow) server = relationship("Server") __table_args__ = ( Index("idx_alert_logs_server_id", "server_id"), Index("idx_alert_logs_created_at", "created_at"), Index("idx_alert_logs_type", "alert_type"), ) class SshSession(Base): """Web SSH session lifecycle tracking""" __tablename__ = "ssh_sessions" id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID") server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False, comment="服务器ID") 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=_utcnow) closed_at = Column(DateTime, nullable=True) server = relationship("Server") admin = relationship("Admin") command_logs = relationship("CommandLog", back_populates="session", cascade="all, delete-orphan") __table_args__ = ( Index('idx_ssh_sessions_server_id', 'server_id'), Index('idx_ssh_sessions_admin_id', 'admin_id'), ) class CommandLog(Base): """SSH command log — record every command executed in terminal""" __tablename__ = "command_logs" id = Column(Integer, primary_key=True, autoincrement=True) session_id = Column(String(36), ForeignKey("ssh_sessions.id", ondelete="CASCADE"), nullable=True, comment="SSH会话ID") server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), nullable=False, comment="服务器ID") 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=_utcnow) session = relationship("SshSession", back_populates="command_logs") server = relationship("Server") admin = relationship("Admin") __table_args__ = ( Index('idx_cmdlog_srv_time', 'server_id', 'created_at'), Index('idx_cmdlog_session_id', 'session_id'), ) # ────────────────────────────────────────────── # Quick Commands (终端快捷命令) # ────────────────────────────────────────────── class QuickCommand(Base): """Terminal quick commands — persistent across browsers, shareable across users""" __tablename__ = "quick_commands" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), nullable=False, comment="命令名称") cmd = Column(String(500), nullable=False, comment="命令内容") is_builtin = Column(Boolean, default=False, comment="系统内置标记(不可删除/编辑)") sort_order = Column(Integer, default=0, comment="排序权重") created_by = Column(String(100), nullable=True, comment="创建人") created_at = Column(DateTime, default=_utcnow) updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)