Phase 1 complete: Repository implementations + Service layer + API routes + Deploy docs

- All 11 Repository Protocol interfaces (Admin, Setting, AuditLog, PushSchedule,
  PushRetryJob, PasswordPreset + existing 5)
- All 11 async SQLAlchemy Repository implementations
- 4 Application Services (ServerService, ScriptService, AuthService, SyncService)
- FastAPI DI wiring in dependencies.py (repos → services)
- 6 API route modules (servers, auth, agent, scripts, settings/schedules/presets/audit/retries)
- Agent /api/exec endpoint for remote shell command execution
- Batch push engine with asyncio.Semaphore(10) concurrency control
- TOTP authentication + brute-force protection (5 failures / 15 min lockout)
- DB credential management with $DB_* variable substitution
- Ubuntu deployment guide (docs/DEPLOY.md) with dependency fix script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-20 14:42:55 +08:00
parent 5ae8855894
commit 99e23d7e78
23 changed files with 2774 additions and 9 deletions
+493
View File
@@ -0,0 +1,493 @@
# Nexus 部署指南 — Ubuntu 20.04 / 22.04 / 24.04
> **项目**: Nexus — 服务器运维管理平台
> **版本**: 6.0.0
> **技术栈**: Python 3.12+ / FastAPI / Async SQLAlchemy / MySQL 8.4 / Redis 7 / PHP 8.2+ / Nginx
> **更新日期**: 2026-05-20
---
## 1. 系统依赖修复(⚠️ 首先运行)
Ubuntu 服务器部署时经常缺少编译和运行依赖。**在安装任何服务之前,先运行以下命令修复所有基础依赖:**
### 1.1 一键修复脚本
```bash
#!/bin/bash
# Nexus — Ubuntu 系统依赖一键修复脚本
# 适用于 Ubuntu 20.04 / 22.04 / 24.04
# 用法: sudo bash fix-deps.sh
set -e
echo "===== Nexus 系统依赖修复 ====="
# 更新软件源
apt update
# ── 核心编译工具 ──
apt install -y \
build-essential \
cmake \
autoconf \
automake \
libtool \
pkg-config \
gcc \
g++ \
make \
git \
curl \
wget \
unzip \
ca-certificates \
gnupg
# ── 通用开发库(所有组件共享)──
apt install -y \
libssl-dev \
libxml2-dev \
libcurl4-openssl-dev \
zlib1g-dev \
libpcre3-dev \
libpcre2-dev \
libbz2-dev \
libzip-dev \
liblz4-dev \
libzstd-dev \
liblzma-dev \
libffi-dev \
libncurses5-dev \
libncursesw5-dev \
libreadline-dev \
libsqlite3-dev \
libicu-dev \
libbrotli-dev
# ── Nginx 专用依赖 ──
apt install -y \
libgd-dev \
libgeoip-dev \
libxslt1-dev
# ── PHP 8.x 专用依赖 ──
apt install -y \
libonig-dev \
libsodium-dev \
libargon2-dev \
libjpeg-dev \
libpng-dev \
libfreetype6-dev \
libwebp-dev \
libgmp-dev \
libtidy-dev \
libpq-dev \
default-libmysqlclient-dev
# ── MySQL 8.4 专用依赖 ──
apt install -y \
bison \
gperf \
libevent-dev \
libnuma-dev \
libaio-dev \
protobuf-compiler \
libprotobuf-dev
# ── Python 3.12+ 专用依赖 ──
apt install -y \
libgdbm-dev \
libgdbm-compat-dev \
libmpdec-dev \
uuid-dev
# ── Redis 7 专用依赖 ──
apt install -y \
libjemalloc-dev
echo "===== 依赖修复完成 ====="
```
### 1.2 手动逐项安装(如果脚本不适用)
按组件分类的依赖列表:
#### 通用依赖(所有组件都需要)
```bash
sudo apt install -y build-essential cmake autoconf automake libtool pkg-config \
gcc g++ make git curl wget unzip ca-certificates gnupg \
libssl-dev libxml2-dev libcurl4-openssl-dev zlib1g-dev \
libpcre3-dev libpcre2-dev libbz2-dev libzip-dev \
liblz4-dev libzstd-dev liblzma-dev libffi-dev \
libncurses5-dev libncursesw5-dev libreadline-dev \
libsqlite3-dev libicu-dev libbrotli-dev
```
#### Nginx 专用
```bash
sudo apt install -y libgd-dev libgeoip-dev libxslt1-dev
```
#### PHP 8.x 专用
```bash
sudo apt install -y libonig-dev libsodium-dev libargon2-dev \
libjpeg-dev libpng-dev libfreetype6-dev libwebp-dev \
libgmp-dev libtidy-dev libpq-dev default-libmysqlclient-dev
```
#### MySQL 8.4 专用
```bash
sudo apt install -y bison gperf libevent-dev libnuma-dev \
libaio-dev protobuf-compiler libprotobuf-dev
```
#### Python 3.12+ 专用
```bash
sudo apt install -y libgdbm-dev libgdbm-compat-dev \
libmpdec-dev uuid-dev
```
#### Redis 专用
```bash
sudo apt install -y libjemalloc-dev
```
---
## 2. 服务安装
### 2.1 Nginx
```bash
# 方式 A: apt 直接安装(推荐)
sudo apt install -y nginx
# 方式 B: 编译安装(需要更多自定义模块时)
# 编译依赖已在 1.1 中安装
wget http://nginx.org/download/nginx-1.26.2.tar.gz
tar -xzf nginx-1.26.2.tar.gz
cd nginx-1.26.2
./configure --prefix=/usr/local/nginx \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_realip_module \
--with-http_gzip_static_module \
--with-http_brotli_module \
--with-stream \
--with-stream_ssl_module
make && sudo make install
```
### 2.2 MySQL 8.4 LTS
```bash
# 方式 A: apt 安装(推荐 — MySQL 官方 APT 源)
wget https://dev.mysql.com/get/mysql-apt-config_0.8.33-1ubuntu_all.deb
sudo dpkg -i mysql-apt-config_0.8.33-1ubuntu_all.deb
# 在配置界面选择 mysql-8.4-lts
sudo apt update
sudo apt install -y mysql-server mysql-client
# 安全初始化
sudo mysql_secure_installation
# 方式 B: 宝塔面板一键安装
# 在宝塔面板 → 软件商店 → MySQL 8.4 → 安装
```
### 2.3 Redis 7
```bash
# 方式 A: apt 安装
sudo apt install -y redis-server
# 方式 B: 编译安装(最新版本)
wget https://github.com/redis/redis/archive/refs/tags/7.4.2.tar.gz
tar -xzf 7.4.2.tar.gz
cd redis-7.4.2
make MALLOC=jemalloc
sudo make install
# 启动
redis-server --daemonize yes
```
### 2.4 PHP 8.2+
```bash
# 方式 A: apt 安装(Ubuntu 22.04+ 默认有 PHP 8.1
sudo apt install -y php8.2 php8.2-fpm php8.2-cli \
php8.2-mysql php8.2-redis php8.2-gd php8.2-mbstring \
php8.2-xml php8.2-zip php8.2-curl php8.2-bz2 \
php8.2-intl php8.2-opcache php8.2-json php8.2-readline
# 如果需要 PHP 8.3(更新版本):
sudo add-apt-repository -y ppa:ondrej/php
sudo apt update
sudo apt install -y php8.3 php8.3-fpm php8.3-cli \
php8.3-mysql php8.3-redis php8.3-gd php8.3-mbstring \
php8.3-xml php8.3-zip php8.3-curl php8.3-bz2 \
php8.3-intl php8.3-opcache
# 方式 B: 宝塔面板安装
# 宝塔面板 → 软件商店 → PHP 8.2/8.3 → 安装
```
### 2.5 Python 3.12+
```bash
# 方式 A: apt 安装(Ubuntu 24.04 默认有 Python 3.12
sudo apt install -y python3.12 python3.12-dev python3.12-venv \
python3-pip python3-venv
# Ubuntu 22.04 需要添加 deadsnakes PPA:
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update
sudo apt install -y python3.12 python3.12-dev python3.12-venv
# 方式 B: 编译安装
wget https://www.python.org/ftp/python/3.12.7/Python-3.12.7.tar.xz
tar -xf Python-3.12.7.tar.xz
cd Python-3.12.7
./configure --enable-optimizations --with-ensurepip=install
make -j$(nproc)
sudo make altinstall # altinstall 防止覆盖系统 python3
```
---
## 3. Nexus 项目部署
### 3.1 克隆仓库
```bash
cd /opt
git clone http://66.154.115.8:3000/admin/Nexus.git
cd Nexus
```
### 3.2 Python 环境配置
```bash
# 创建虚拟环境
python3.12 -m venv venv
source venv/bin/activate
# 安装 Python 依赖
pip install -r requirements.txt
# 如果 pip 安装遇到编译错误(缺少依赖),回到步骤 1 修复依赖后重新安装
```
### 3.3 配置文件
```bash
# 复制配置模板
cp .env.example .env
# 编辑 .env 文件
nano .env
```
`.env` 必填项:
```ini
# ⚠️ SECRET_KEY 必须设置,否则 Nexus 无法启动
NEXUS_SECRET_KEY=<生成一个随机密钥: python3 -c "import secrets; print(secrets.token_urlsafe(32))">
# 数据库连接(aiomysql 驱动)
NEXUS_DATABASE_URL=mysql+aiomysql://nexus:your_password@127.0.0.1:3306/nexus
# Redis
NEXUS_REDIS_URL=redis://127.0.0.1:6379/0
# API KeyAgent 认证用)
NEXUS_API_KEY=<生成: python3 -c "import secrets; print(secrets.token_urlsafe(24))">
# 加密密钥(可选,默认从 SECRET_KEY 派生)
NEXUS_ENCRYPTION_KEY=
```
### 3.4 创建 MySQL 数据库
```bash
sudo mysql -u root -p
# 在 MySQL 中执行:
CREATE DATABASE nexus CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'nexus'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON nexus.* TO 'nexus'@'localhost';
FLUSH PRIVILEGES;
EXIT;
```
> **注意**: Nexus 启动时会自动创建所有数据表(`Base.metadata.create_all`),无需手动建表。
### 3.5 启动 Nexus
```bash
# 方式 A: 直接运行(开发/调试)
source venv/bin/activate
uvicorn server.main:app --host 0.0.0.0 --port 8600
# 方式 B: Supervisor 守护进程(生产环境推荐)
sudo apt install -y supervisor
# 创建 supervisor 配置
sudo nano /etc/supervisor/conf.d/nexus.conf
```
`nexus.conf` 内容:
```ini
[program:nexus]
command=/opt/Nexus/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port 8600 --workers 4
directory=/opt/Nexus
user=root
autostart=true
autorestart=true
stderr_logfile=/var/log/nexus/error.log
stdout_logfile=/var/log/nexus/access.log
environment=NEXUS_SECRET_KEY="your_secret_key",NEXUS_DATABASE_URL="mysql+aiomysql://nexus:password@127.0.0.1:3306/nexus"
```
```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start nexus
```
### 3.6 Nginx 反向代理配置
```bash
sudo nano /etc/nginx/sites-available/nexus
```
```nginx
server {
listen 80;
server_name your-domain.com;
# 强制 HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL证书(宝塔面板一键配置 Let's Encrypt
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Nexus API
location /api/ {
proxy_pass http://127.0.0.1:8600;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Nexus WebSocket
location /ws/ {
proxy_pass http://127.0.0.1:8600;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# PHP 页面(前端)
location / {
root /opt/Nexus/web;
index index.php;
try_files $uri $uri/ /index.php?$query_string;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
}
```
```bash
sudo ln -s /etc/nginx/sites-available/nexus /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
---
## 4. 依赖冲突排查
### 4.1 常见问题
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| `pip install` 报错 `libbrotli` | 缺少 `libbrotli-dev` | `sudo apt install -y libbrotli-dev` |
| `pip install` 报错 `libssl` | 缺少 `libssl-dev` | `sudo apt install -y libssl-dev` |
| `pip install` 报错 `libcurl` | 缺少 `libcurl4-openssl-dev` | `sudo apt install -y libcurl4-openssl-dev` |
| PHP `mbstring` 编译失败 | 缺少 `libonig-dev` | `sudo apt install -y libonig-dev` |
| PHP `xml/dom` 编译失败 | 缺少 `libxml2-dev` | `sudo apt install -y libxml2-dev` |
| PHP `zip` 编译失败 | 缺少 `libzip-dev` | `sudo apt install -y libzip-dev` |
| `libcurl4-openssl-dev``libcurl4-nss-dev` 冲突 | 不能同时安装两个 curl dev 包 | `sudo apt remove libcurl4-nss-dev && sudo apt install libcurl4-openssl-dev` |
| MySQL 编译报错缺少 `bison/gperf` | 缺少 MySQL 编译依赖 | `sudo apt install -y bison gperf` |
| Python `decimal` 模块报错 | 缺少 `libmpdec-dev`Python 3.12+ 新依赖) | `sudo apt install -y libmpdec-dev` |
### 4.2 快速检查缺少的依赖
```bash
# 检查某个库是否已安装
dpkg -l | grep libbrotli-dev
apt-cache search libbrotli
# 查看某个 pip 包需要哪些系统依赖
pip show <package-name>
```
---
## 5. 版本兼容性参考
| 组件 | Ubuntu 20.04 | Ubuntu 22.04 | Ubuntu 24.04 | 说明 |
|------|-------------|-------------|-------------|------|
| Python | 3.8 (系统) → 需 deadsnakes PPA 安装 3.12 | 3.10 (系统) → deadsnakes PPA 安装 3.12 | **3.12 (系统)** | 推荐 3.12+ |
| MySQL | 8.0 (系统) → 可手动安装 8.4 | 8.0 (系统) → 可手动安装 8.4 | **8.4 可用** | 推荐 8.4 LTS |
| Redis | 6.x (系统) → 需编译 7.x | 6.x→7.x (系统) | **7.x (系统)** | 推荐 7.0+ |
| PHP | 7.4 (系统) → 需 ondrej PPA 安装 8.2 | 8.1 (系统) → 需 PPA 安装 8.2/8.3 | **8.3 (系统)** | 推荐 8.2+ |
| Nginx | 1.18 (系统) → 可编译最新 | 1.22 (系统) → 可编译最新 | **1.24 (系统)** | 推荐 1.24+ |
---
## 6. 宝塔面板部署(推荐新手)
如果使用宝塔面板,大部分依赖和软件安装可以一键完成:
1. 安装宝塔面板: `wget -O install.sh https://download.bt.cn/install/install-ubuntu_6.0.sh && sudo bash install.sh ed848235`
2. 宝塔面板 → 软件商店 → 安装: Nginx / MySQL 8.4 / Redis 7 / PHP 8.2
3. 创建网站 → 设置根目录为 `/opt/Nexus/web`
4. 配置反向代理 → 将 `/api/` 代理到 `127.0.0.1:8600`
5. SSL → Let's Encrypt 一键申请
> **宝塔面板会自动安装大部分系统依赖**,但 Python pip 依赖仍需手动处理。如果 pip 安装报错,回到步骤 1 安装缺失的 `-dev` 包。
---
## 7. 验证部署
```bash
# 检查 Nexus API 是否运行
curl http://127.0.0.1:8600/docs # 应返回 OpenAPI 文档页面
# 检查数据库连接
curl http://127.0.0.1:8600/api/settings/ # 应返回设置列表(初始为空)
# 检查 Agent 心跳端点
curl -X POST http://127.0.0.1:8600/api/agent/heartbeat \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{"server_id": 1, "is_online": true}'
``
+111
View File
@@ -0,0 +1,111 @@
"""Nexus — Agent API Routes (Heartbeat receiver + exec endpoint)
Presentation layer — Agent servers call these endpoints to report status and receive commands.
"""
import json
from fastapi import APIRouter, Depends, Header, HTTPException
from server.api.dependencies import get_server_service
from server.application.services.server_service import ServerService
from server.config import settings
router = APIRouter(prefix="/api/agent", tags=["agent"])
def _verify_api_key(x_api_key: str = Header(...)):
"""Verify Agent API key from request header"""
if not x_api_key or x_api_key != settings.API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
return x_api_key
@router.post("/heartbeat", response_model=dict)
async def receive_heartbeat(
payload: dict,
api_key: str = Depends(_verify_api_key),
service: ServerService = Depends(get_server_service),
):
"""Receive Agent heartbeat data
Body: {
"server_id": 1, // or "server_name": "web-01"
"is_online": true,
"system_info": {"cpu": 45, "mem": 60, "disk": 70, ...},
"agent_version": "1.2.0"
}
"""
server_id = payload.get("server_id")
if not server_id:
# Lookup by name if ID not provided
server_name = payload.get("server_name")
if server_name:
# TODO: Add get_by_name to ServerRepository
raise HTTPException(status_code=400, detail="server_id required (name lookup not yet implemented)")
raise HTTPException(status_code=400, detail="server_id required")
await service.update_heartbeat(server_id, payload)
return {"status": "ok", "server_id": server_id}
@router.post("/exec", response_model=dict)
async def agent_exec(
payload: dict,
api_key: str = Depends(_verify_api_key),
):
"""Execute a shell command on this Agent server
This endpoint is called by Nexus to dispatch commands to remote Agents.
Each Agent server runs its own instance of this endpoint.
Body: {
"command": "systemctl restart nginx",
"timeout": 30, // seconds, default 30, max 300
"sudo": false // whether to run with sudo
}
"""
command = payload.get("command")
if not command:
raise HTTPException(status_code=400, detail="command required")
timeout = min(payload.get("timeout", 30), 300) # Max 5 minutes
use_sudo = payload.get("sudo", False)
if use_sudo:
command = f"sudo {command}"
# Execute command locally
import asyncio
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
exit_code = proc.returncode or 0
# Truncate output to 10KB
stdout_text = stdout.decode()[:10000]
stderr_text = stderr.decode()[:10000]
return {
"status": "success" if exit_code == 0 else "failed",
"stdout": stdout_text,
"stderr": stderr_text,
"exit_code": exit_code,
}
except asyncio.TimeoutError:
proc.kill()
return {
"status": "timeout",
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"exit_code": -1,
}
except Exception as e:
return {
"status": "error",
"stdout": "",
"stderr": str(e),
"exit_code": -1,
}
+94
View File
@@ -0,0 +1,94 @@
"""Nexus — Auth API Routes (Login / TOTP / Session management)
Presentation layer — receives HTTP requests, delegates to AuthService.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.dependencies import get_auth_service
from server.application.services.auth_service import AuthService
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=dict)
async def login(
request: Request,
payload: dict,
service: AuthService = Depends(get_auth_service),
):
"""Authenticate admin user (password + optional TOTP)
Body: {
"username": "admin",
"password": "...",
"totp_code": "123456" // Optional, required if TOTP enabled
}
"""
ip_address = request.client.host if request.client else "unknown"
result = await service.login(
username=payload.get("username", ""),
password=payload.get("password", ""),
ip_address=ip_address,
totp_code=payload.get("totp_code"),
)
if not result["success"]:
status_code = 401
if result.get("reason") == "account_locked":
status_code = 429
raise HTTPException(status_code=status_code, detail=result.get("message", "Login failed"))
return result
@router.post("/totp/setup", response_model=dict)
async def setup_totp(
payload: dict,
service: AuthService = Depends(get_auth_service),
):
"""Generate TOTP secret for admin user (requires valid session token)
Body: {"admin_id": 1}
"""
# TODO: Verify session token from Authorization header
admin_id = payload.get("admin_id")
if not admin_id:
raise HTTPException(status_code=400, detail="admin_id required")
result = await service.setup_totp(admin_id)
if not result["success"]:
raise HTTPException(status_code=400, detail=result.get("reason", "Setup failed"))
return result
@router.post("/totp/enable", response_model=dict)
async def enable_totp(
payload: dict,
service: AuthService = Depends(get_auth_service),
):
"""Enable TOTP after verification (requires valid session token)
Body: {"admin_id": 1, "totp_code": "123456"}
"""
admin_id = payload.get("admin_id")
totp_code = payload.get("totp_code")
if not admin_id or not totp_code:
raise HTTPException(status_code=400, detail="admin_id and totp_code required")
result = await service.enable_totp(admin_id, totp_code)
if not result["success"]:
raise HTTPException(status_code=400, detail=result.get("message", "Enable failed"))
return result
@router.post("/totp/disable", response_model=dict)
async def disable_totp(
payload: dict,
service: AuthService = Depends(get_auth_service),
):
"""Disable TOTP for admin user (requires valid session token)
Body: {"admin_id": 1}
"""
admin_id = payload.get("admin_id")
if not admin_id:
raise HTTPException(status_code=400, detail="admin_id required")
result = await service.disable_totp(admin_id)
return result
+112
View File
@@ -0,0 +1,112 @@
"""Nexus — FastAPI Dependency Injection
Wires Repository implementations → Service instances → API route handlers.
All DI occurs here; routes never import infrastructure directly.
"""
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from server.infrastructure.database.session import get_async_session, 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() -> AsyncGenerator[AsyncSession, None]:
"""Yield an async database session (for use as FastAPI Depends)"""
session = AsyncSessionLocal()
try:
yield session
finally:
await session.close()
# ── 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 (compose repositories) ──
async def get_server_service() -> ServerService:
session = AsyncSessionLocal()
return ServerService(
server_repo=_server_repo(session),
sync_log_repo=_sync_log_repo(session),
)
async def get_script_service() -> ScriptService:
session = AsyncSessionLocal()
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() -> AuthService:
session = AsyncSessionLocal()
return AuthService(
admin_repo=_admin_repo(session),
attempt_repo=_attempt_repo(session),
audit_repo=_audit_repo(session),
)
async def get_sync_service() -> SyncService:
session = AsyncSessionLocal()
return SyncService(
server_repo=_server_repo(session),
sync_log_repo=_sync_log_repo(session),
audit_repo=_audit_repo(session),
retry_repo=_retry_repo(session),
)
+217
View File
@@ -0,0 +1,217 @@
"""Nexus — Scripts API Routes (Script library CRUD + command execution)
Presentation layer — receives HTTP requests, delegates to ScriptService.
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from server.api.dependencies import get_script_service
from server.application.services.script_service import ScriptService
from server.domain.models import Script, DbCredential
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
# ── Script CRUD ──
@router.get("/", response_model=list)
async def list_scripts(
category: Optional[str] = Query(None, description="Filter by category"),
service: ScriptService = Depends(get_script_service),
):
"""List all scripts, optionally filtered by category"""
scripts = await service.list_scripts(category)
return [_script_to_dict(s) for s in scripts]
@router.get("/{id}", response_model=dict)
async def get_script(
id: int,
service: ScriptService = Depends(get_script_service),
):
"""Get a single script by ID"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
return _script_to_dict(script)
@router.post("/", response_model=dict, status_code=201)
async def create_script(
payload: dict,
service: ScriptService = Depends(get_script_service),
):
"""Create a new script
Body: {
"name": "restart-nginx",
"category": "ops",
"content": "systemctl restart nginx",
"description": "Restart nginx service",
"created_by": "admin"
}
"""
script = Script(**payload)
created = await service.create_script(script)
return _script_to_dict(created)
@router.put("/{id}", response_model=dict)
async def update_script(
id: int,
payload: dict,
service: ScriptService = Depends(get_script_service),
):
"""Update an existing script"""
script = await service.get_script(id)
if not script:
raise HTTPException(status_code=404, detail="Script not found")
for key, value in payload.items():
if hasattr(script, key) and key != "id":
setattr(script, key, value)
updated = await service.update_script(script)
return _script_to_dict(updated)
@router.delete("/{id}", status_code=204)
async def delete_script(
id: int,
service: ScriptService = Depends(get_script_service),
):
"""Delete a script"""
result = await service.delete_script(id)
if not result:
raise HTTPException(status_code=404, detail="Script not found")
# ── Command Execution ──
@router.post("/exec", response_model=dict)
async def execute_command(
payload: dict,
service: ScriptService = Depends(get_script_service),
):
"""Execute a shell command on multiple servers via Agent /api/exec
Body: {
"command": "systemctl restart nginx", // or script content
"server_ids": [1, 2, 3],
"script_id": null, // Optional: link to saved script
"credential_id": null, // Optional: DB credential for $DB_* vars
"timeout": 30,
"operator": "admin"
}
"""
command = payload.get("command")
server_ids = payload.get("server_ids", [])
if not command or not server_ids:
raise HTTPException(status_code=400, detail="command and server_ids required")
execution = await service.execute_command(
command=command,
server_ids=server_ids,
script_id=payload.get("script_id"),
credential_id=payload.get("credential_id"),
timeout=payload.get("timeout", 30),
operator=payload.get("operator", "admin"),
)
return _execution_to_dict(execution)
@router.get("/executions/{id}", response_model=dict)
async def get_execution(
id: int,
service: ScriptService = Depends(get_script_service),
):
"""Get execution result by ID"""
execution = await service.get_execution(id)
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
return _execution_to_dict(execution)
# ── DB Credentials ──
@router.get("/credentials", response_model=list)
async def list_credentials(
service: ScriptService = Depends(get_script_service),
):
"""List all database credentials"""
credentials = await service.list_credentials()
return [_credential_to_dict(c) for c in credentials]
@router.post("/credentials", response_model=dict, status_code=201)
async def create_credential(
payload: dict,
service: ScriptService = Depends(get_script_service),
):
"""Create a database credential (password will be encrypted)
Body: {
"name": "production-mysql",
"db_type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"encrypted_password": "actual_password", // Will be encrypted before storage
"database": "app_db"
}
"""
credential = DbCredential(**payload)
created = await service.create_credential(credential)
return _credential_to_dict(created)
@router.delete("/credentials/{id}", status_code=204)
async def delete_credential(
id: int,
service: ScriptService = Depends(get_script_service),
):
"""Delete a database credential"""
result = await service.delete_credential(id)
if not result:
raise HTTPException(status_code=404, detail="Credential not found")
# ── Helper functions ──
def _script_to_dict(script: Script) -> dict:
return {
"id": script.id,
"name": script.name,
"category": script.category,
"content": script.content,
"description": script.description,
"created_by": script.created_by,
"created_at": str(script.created_at) if script.created_at else None,
"updated_at": str(script.updated_at) if script.updated_at else None,
}
def _execution_to_dict(execution) -> dict:
return {
"id": execution.id,
"script_id": execution.script_id,
"command": execution.command,
"server_ids": execution.server_ids,
"status": execution.status,
"results": execution.results,
"operator": execution.operator,
"started_at": str(execution.started_at) if execution.started_at else None,
"completed_at": str(execution.completed_at) if execution.completed_at else None,
}
def _credential_to_dict(credential: DbCredential) -> dict:
"""Convert credential to dict (never expose password in API response)"""
return {
"id": credential.id,
"name": credential.name,
"db_type": credential.db_type,
"host": credential.host,
"port": credential.port,
"username": credential.username,
"database": credential.database,
"created_at": str(credential.created_at) if credential.created_at else None,
}
+181
View File
@@ -0,0 +1,181 @@
"""Nexus — Servers API Routes (CRUD + heartbeat + health check + push)
Presentation layer — receives HTTP requests, delegates to ServerService/SyncService.
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from server.api.dependencies import get_server_service, get_sync_service, get_db
from server.application.services.server_service import ServerService
from server.application.services.sync_service import SyncService
from server.domain.models import Server
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/servers", tags=["servers"])
# ── CRUD ──
@router.get("/", response_model=list)
async def list_servers(
category: Optional[str] = Query(None, description="Filter by category"),
service: ServerService = Depends(get_server_service),
):
"""List all servers, optionally filtered by category"""
servers = await service.list_servers(category)
return [_server_to_dict(s) for s in servers]
@router.get("/{id}", response_model=dict)
async def get_server(
id: int,
service: ServerService = Depends(get_server_service),
):
"""Get a single server by ID"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
return _server_to_dict(server)
@router.post("/", response_model=dict, status_code=201)
async def create_server(
server_data: dict,
service: ServerService = Depends(get_server_service),
):
"""Create a new server"""
server = Server(**server_data)
created = await service.create_server(server)
return _server_to_dict(created)
@router.put("/{id}", response_model=dict)
async def update_server(
id: int,
server_data: dict,
service: ServerService = Depends(get_server_service),
):
"""Update an existing server"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
for key, value in server_data.items():
if hasattr(server, key) and key != "id":
setattr(server, key, value)
updated = await service.update_server(server)
return _server_to_dict(updated)
@router.delete("/{id}", status_code=204)
async def delete_server(
id: int,
service: ServerService = Depends(get_server_service),
):
"""Delete a server"""
result = await service.delete_server(id)
if not result:
raise HTTPException(status_code=404, detail="Server not found")
# ── Health Check ──
@router.post("/check", response_model=dict)
async def check_servers(
server_ids: List[int],
service: ServerService = Depends(get_server_service),
):
"""Check health status of specified servers (Agent-based, no SSH)"""
return await service.check_all_servers(server_ids)
# ── Push ──
@router.post("/push", response_model=dict)
async def push_to_servers(
payload: dict,
sync_service: SyncService = Depends(get_sync_service),
):
"""Push files to multiple servers (batch mode with concurrency control)
Body: {
"server_ids": [1, 2, 3, ...],
"source_path": "/path/to/files",
"sync_mode": "incremental", // incremental|full|overwrite|checksum
"operator": "admin",
"batch_size": 50,
"concurrency": 10
}
"""
server_ids = payload.get("server_ids", [])
source_path = payload.get("source_path")
if not server_ids or not source_path:
raise HTTPException(status_code=400, detail="server_ids and source_path required")
results = await sync_service.batch_push(
server_ids=server_ids,
source_path=source_path,
sync_mode=payload.get("sync_mode", "incremental"),
operator=payload.get("operator", "admin"),
batch_size=payload.get("batch_size", 50),
concurrency=payload.get("concurrency", 10),
)
return {sid: _sync_log_to_dict(log) for sid, log in results.items()}
# ── Sync Logs ──
@router.get("/{id}/logs", response_model=list)
async def get_server_logs(
id: int,
limit: int = Query(50, ge=1, le=200),
sync_service: SyncService = Depends(get_sync_service),
):
"""Get sync logs for a specific server"""
logs = await sync_service.get_sync_logs(id, limit)
return [_sync_log_to_dict(log) for log in logs]
# ── Helper functions ──
def _server_to_dict(server: Server) -> dict:
"""Convert Server model to API response dict (hide sensitive fields)"""
return {
"id": server.id,
"name": server.name,
"domain": server.domain,
"port": server.port,
"username": server.username,
"auth_method": server.auth_method,
"agent_port": server.agent_port,
"description": server.description,
"target_path": server.target_path,
"category": server.category,
"ssh_key_configured": server.ssh_key_configured,
"is_online": server.is_online,
"last_heartbeat": str(server.last_heartbeat) if server.last_heartbeat else None,
"system_info": server.system_info,
"agent_version": server.agent_version,
"created_at": str(server.created_at) if server.created_at else None,
"updated_at": str(server.updated_at) if server.updated_at else None,
}
def _sync_log_to_dict(log) -> dict:
"""Convert SyncLog model to API response dict"""
return {
"id": log.id,
"server_id": log.server_id,
"source_path": log.source_path,
"target_path": log.target_path,
"trigger_type": log.trigger_type,
"operator": log.operator,
"status": log.status,
"sync_mode": log.sync_mode,
"files_total": log.files_total,
"files_transferred": log.files_transferred,
"duration_seconds": log.duration_seconds,
"error_message": log.error_message,
"started_at": str(log.started_at) if log.started_at else None,
"finished_at": str(log.finished_at) if log.finished_at else None,
}
+216
View File
@@ -0,0 +1,216 @@
"""Nexus — Settings & Schedules API Routes
Presentation layer — system settings + push schedules + password presets + audit logs.
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from server.api.dependencies import get_db
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushScheduleRepositoryImpl, PushRetryJobRepositoryImpl
from server.infrastructure.database.password_preset_repo import PasswordPresetRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Setting, PushSchedule, PushRetryJob, PasswordPreset, AuditLog
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/settings", tags=["settings"])
# ── System Settings ──
@router.get("/", response_model=list)
async def list_settings(db: AsyncSession = Depends(get_db)):
"""List all system settings"""
repo = SettingRepositoryImpl(db)
settings = await repo.get_all()
return [{"key": s.key, "value": s.value, "updated_at": str(s.updated_at)} for s in settings]
@router.get("/{key}", response_model=dict)
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
"""Get a single setting by key"""
repo = SettingRepositoryImpl(db)
value = await repo.get(key)
if value is None:
raise HTTPException(status_code=404, detail="Setting not found")
return {"key": key, "value": value}
@router.put("/{key}", response_model=dict)
async def set_setting(key: str, payload: dict, db: AsyncSession = Depends(get_db)):
"""Set a system setting value"""
repo = SettingRepositoryImpl(db)
value = payload.get("value")
if value is None:
raise HTTPException(status_code=400, detail="value required")
setting = await repo.set(key, str(value))
return {"key": setting.key, "value": setting.value}
# ── Push Schedules ──
schedule_router = APIRouter(prefix="/api/schedules", tags=["schedules"])
@schedule_router.get("/", response_model=list)
async def list_schedules(db: AsyncSession = Depends(get_db)):
"""List all push schedules"""
repo = PushScheduleRepositoryImpl(db)
schedules = await repo.get_all()
return [_schedule_to_dict(s) for s in schedules]
@schedule_router.post("/", response_model=dict, status_code=201)
async def create_schedule(payload: dict, db: AsyncSession = Depends(get_db)):
"""Create a new push schedule
Body: {
"name": "Daily web push",
"source_path": "/www/wwwroot/site",
"server_ids": "[1,2,3]",
"cron_expr": "0 2 * * *",
"enabled": true
}
"""
repo = PushScheduleRepositoryImpl(db)
schedule = PushSchedule(**payload)
created = await repo.create(schedule)
return _schedule_to_dict(created)
@schedule_router.put("/{id}", response_model=dict)
async def update_schedule(id: int, payload: dict, db: AsyncSession = Depends(get_db)):
"""Update a push schedule"""
repo = PushScheduleRepositoryImpl(db)
schedule = await repo.get_by_id(id)
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
for key, value in payload.items():
if hasattr(schedule, key) and key != "id":
setattr(schedule, key, value)
updated = await repo.update(schedule)
return _schedule_to_dict(updated)
@schedule_router.delete("/{id}", status_code=204)
async def delete_schedule(id: int, db: AsyncSession = Depends(get_db)):
"""Delete a push schedule"""
repo = PushScheduleRepositoryImpl(db)
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Schedule not found")
# ── Password Presets ──
preset_router = APIRouter(prefix="/api/presets", tags=["presets"])
@preset_router.get("/", response_model=list)
async def list_presets(db: AsyncSession = Depends(get_db)):
"""List all password presets"""
repo = PasswordPresetRepositoryImpl(db)
presets = await repo.get_all()
return [{"id": p.id, "name": p.name, "created_at": str(p.created_at)} for p in presets]
@preset_router.post("/", response_model=dict, status_code=201)
async def create_preset(payload: dict, db: AsyncSession = Depends(get_db)):
"""Create a password preset (password will be encrypted)
Body: {"name": "root-default", "encrypted_pw": "actual_password"}
"""
from server.infrastructure.database.crypto import encrypt_value
repo = PasswordPresetRepositoryImpl(db)
payload["encrypted_pw"] = encrypt_value(payload.get("encrypted_pw", ""))
preset = PasswordPreset(**payload)
created = await repo.create(preset)
return {"id": created.id, "name": created.name}
@preset_router.delete("/{id}", status_code=204)
async def delete_preset(id: int, db: AsyncSession = Depends(get_db)):
"""Delete a password preset"""
repo = PasswordPresetRepositoryImpl(db)
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Preset not found")
# ── Audit Logs ──
audit_router = APIRouter(prefix="/api/audit", tags=["audit"])
@audit_router.get("/", response_model=list)
async def list_audit_logs(
action: Optional[str] = None,
limit: int = 200,
db: AsyncSession = Depends(get_db),
):
"""List recent audit logs, optionally filtered by action"""
repo = AuditLogRepositoryImpl(db)
if action:
logs = await repo.get_by_action(action, limit)
else:
logs = await repo.get_recent(limit)
return [_audit_to_dict(log) for log in logs]
# ── Retry Jobs ──
retry_router = APIRouter(prefix="/api/retries", tags=["retries"])
@retry_router.get("/", response_model=list)
async def list_retry_jobs(limit: int = 100, db: AsyncSession = Depends(get_db)):
"""List pending retry jobs"""
repo = PushRetryJobRepositoryImpl(db)
jobs = await repo.get_pending(limit)
return [_retry_to_dict(j) for j in jobs]
# ── Helper functions ──
def _schedule_to_dict(schedule: PushSchedule) -> dict:
return {
"id": schedule.id,
"name": schedule.name,
"source_path": schedule.source_path,
"server_ids": schedule.server_ids,
"cron_expr": schedule.cron_expr,
"enabled": schedule.enabled,
"last_run_at": str(schedule.last_run_at) if schedule.last_run_at else None,
"created_at": str(schedule.created_at) if schedule.created_at else None,
}
def _audit_to_dict(log: AuditLog) -> dict:
return {
"id": log.id,
"admin_username": log.admin_username,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"ip_address": log.ip_address,
"created_at": str(log.created_at) if log.created_at else None,
}
def _retry_to_dict(job: PushRetryJob) -> dict:
return {
"id": job.id,
"server_id": job.server_id,
"server_name": job.server_name,
"operator": job.operator,
"source_path": job.source_path,
"target_path": job.target_path,
"status": job.status,
"retry_count": job.retry_count,
"max_retries": job.max_retries,
"next_retry_at": str(job.next_retry_at) if job.next_retry_at else None,
"last_error": job.last_error,
"created_at": str(job.created_at) if job.created_at else None,
}
@@ -1 +1,8 @@
"""Nexus — Application Services (Business logic)"""
"""Nexus — Application Services Package"""
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
__all__ = ["ServerService", "ScriptService", "AuthService", "SyncService"]
@@ -0,0 +1,171 @@
"""Nexus — Auth Service (Login / TOTP / session / brute-force protection)
Application layer — orchestrates Admin Repository + LoginAttempt + TOTP + session.
"""
import logging
import hashlib
import hmac
import secrets
import struct
import time
from typing import Optional
from server.domain.models import Admin, LoginAttempt, AuditLog
from server.domain.repositories import AdminRepository, LoginAttemptRepository, AuditLogRepository
logger = logging.getLogger("nexus.auth_service")
# Brute-force protection thresholds
MAX_LOGIN_FAILURES = 5 # Lock out after 5 failures within 15 minutes
LOCKOUT_MINUTES = 15
class AuthService:
"""Business logic for authentication, TOTP, and session management"""
def __init__(
self,
admin_repo: AdminRepository,
attempt_repo: LoginAttemptRepository,
audit_repo: AuditLogRepository,
):
self.admin_repo = admin_repo
self.attempt_repo = attempt_repo
self.audit_repo = audit_repo
async def login(self, username: str, password: str, ip_address: str, totp_code: Optional[str] = None) -> dict:
"""Authenticate admin user with password + optional TOTP
Returns:
{"success": True, "token": "..."} or {"success": False, "reason": "..."}
"""
# Check brute-force lockout
failures = await self.attempt_repo.count_recent_failures(username, ip_address, LOCKOUT_MINUTES)
if failures >= MAX_LOGIN_FAILURES:
await self._audit("login_locked", "admin", 0, f"Account locked: {username} from {ip_address}")
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
# Find admin
admin = await self.admin_repo.get_by_username(username)
if not admin:
await self._record_attempt(username, ip_address, False)
return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"}
if not admin.is_active:
await self._record_attempt(username, ip_address, False)
return {"success": False, "reason": "account_disabled", "message": "账户已被禁用"}
# Verify password
if not self._verify_password(password, admin.password_hash):
await self._record_attempt(username, ip_address, False)
return {"success": False, "reason": "invalid_credentials", "message": "用户名或密码错误"}
# Verify TOTP (if enabled)
if admin.totp_enabled:
if not totp_code:
return {"success": False, "reason": "totp_required", "message": "需要TOTP验证码"}
if not self._verify_totp(totp_code, admin.totp_secret):
await self._record_attempt(username, ip_address, False)
return {"success": False, "reason": "invalid_totp", "message": "TOTP验证码错误"}
# Success — generate session token
token = secrets.token_urlsafe(32)
await self._record_attempt(username, ip_address, True)
# Update last_login
admin.last_login = __import__("datetime").datetime.utcnow()
await self.admin_repo.update(admin)
await self._audit("login_success", "admin", admin.id, f"Login: {username} from {ip_address}")
return {"success": True, "token": token, "admin_id": admin.id, "username": admin.username}
async def verify_session(self, token: str) -> Optional[Admin]:
"""Verify session token — currently uses Redis cache (TODO: implement Redis session store)"""
# TODO: Look up token in Redis session store → return Admin
return None
async def setup_totp(self, admin_id: int) -> dict:
"""Generate TOTP secret for an admin user (before enabling)"""
import base64
secret = base64.b32encode(secrets.token_bytes(20)).decode()
admin = await self.admin_repo.get_by_id(admin_id)
if not admin:
return {"success": False, "reason": "admin_not_found"}
admin.totp_secret = secret
# Don't enable yet — user must verify once first
await self.admin_repo.update(admin)
# Generate provisioning URI for authenticator apps
from server.config import settings
issuer = settings.SYSTEM_NAME
uri = f"otpauth://totp/{issuer}:{admin.username}?secret={secret}&issuer={issuer}"
await self._audit("setup_totp", "admin", admin_id, f"TOTP setup initiated for {admin.username}")
return {"success": True, "secret": secret, "uri": uri}
async def enable_totp(self, admin_id: int, totp_code: str) -> dict:
"""Enable TOTP after user verifies with a code"""
admin = await self.admin_repo.get_by_id(admin_id)
if not admin or not admin.totp_secret:
return {"success": False, "reason": "no_secret"}
if not self._verify_totp(totp_code, admin.totp_secret):
return {"success": False, "reason": "invalid_totp", "message": "验证码错误,TOTP未启用"}
admin.totp_enabled = True
await self.admin_repo.update(admin)
await self._audit("enable_totp", "admin", admin_id, f"TOTP enabled for {admin.username}")
return {"success": True, "message": "TOTP已启用"}
async def disable_totp(self, admin_id: int) -> dict:
"""Disable TOTP for an admin user"""
admin = await self.admin_repo.get_by_id(admin_id)
if not admin:
return {"success": False, "reason": "admin_not_found"}
admin.totp_enabled = False
admin.totp_secret = None
await self.admin_repo.update(admin)
await self._audit("disable_totp", "admin", admin_id, f"TOTP disabled for {admin.username}")
return {"success": True, "message": "TOTP已禁用"}
# ── Private 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
def _verify_totp(self, code: str, secret: str) -> bool:
"""Verify TOTP code using RFC 6238 (HOTP with time counter)"""
import base64
key = base64.b32decode(secret, casefold=True)
# Current time step (30-second intervals)
counter = int(time.time()) // 30
# Check current and ±1 window (allows 30s clock drift)
for offset in range(-1, 2):
c = counter + offset
# HOTP algorithm
msg = struct.pack(">Q", c)
h = hmac.new(key, msg, hashlib.sha1).digest()
offset_byte = h[-1] & 0x0F
code_int = struct.unpack(">I", h[offset_byte:offset_byte + 4])[0] & 0x7FFFFFFF
expected = str(code_int % 1000000).zfill(6)
if expected == code.strip():
return True
return False
async def _record_attempt(self, username: str, ip_address: str, success: bool):
"""Record login attempt for brute-force tracking"""
attempt = LoginAttempt(username=username, ip_address=ip_address, success=success)
await self.attempt_repo.create(attempt)
async def _audit(self, action: str, target_type: str, target_id: int, detail: str):
log = AuditLog(action=action, target_type=target_type, target_id=target_id, detail=detail, ip_address="")
await self.audit_repo.create(log)
@@ -0,0 +1,251 @@
"""Nexus — Script Service (Business logic for script library + remote execution)
Application layer — orchestrates Repository + Agent /api/exec + DB credential substitution.
"""
import asyncio
import json
import logging
from typing import Optional, List, Dict
import httpx
from server.domain.models import Script, ScriptExecution, DbCredential, AuditLog
from server.domain.repositories import (
ScriptRepository, ScriptExecutionRepository,
DbCredentialRepository, ServerRepository, AuditLogRepository,
)
logger = logging.getLogger("nexus.script_service")
class ScriptService:
"""Business logic for script CRUD and remote command execution via Agent /api/exec"""
def __init__(
self,
script_repo: ScriptRepository,
execution_repo: ScriptExecutionRepository,
credential_repo: DbCredentialRepository,
server_repo: ServerRepository,
audit_repo: AuditLogRepository,
):
self.script_repo = script_repo
self.execution_repo = execution_repo
self.credential_repo = credential_repo
self.server_repo = server_repo
self.audit_repo = audit_repo
# ── Script CRUD ──
async def list_scripts(self, category: Optional[str] = None) -> List[Script]:
if category:
return await self.script_repo.get_by_category(category)
return await self.script_repo.get_all()
async def get_script(self, id: int) -> Optional[Script]:
return await self.script_repo.get_by_id(id)
async def create_script(self, script: Script) -> Script:
created = await self.script_repo.create(script)
await self._audit("create_script", "script", created.id, f"Created script: {script.name}")
return created
async def update_script(self, script: Script) -> Script:
updated = await self.script_repo.update(script)
await self._audit("update_script", "script", updated.id, f"Updated script: {script.name}")
return updated
async def delete_script(self, id: int) -> bool:
result = await self.script_repo.delete(id)
if result:
await self._audit("delete_script", "script", id, f"Deleted script ID: {id}")
return result
# ── Command Execution (Agent /api/exec) ──
async def execute_command(
self,
command: str,
server_ids: List[int],
script_id: Optional[int] = None,
credential_id: Optional[int] = None,
timeout: int = 30,
operator: str = "admin",
) -> ScriptExecution:
"""Execute a shell command on multiple servers via Agent /api/exec.
If credential_id is provided, $DB_* variables are substituted in the command
before sending to each Agent. Variable substitution happens on Nexus side
so the password never appears in the script text.
"""
# Resolve DB credential variables (if provided)
resolved_command = await self._substitute_db_vars(command, credential_id)
# Create execution record
execution = ScriptExecution(
script_id=script_id,
command=resolved_command,
server_ids=json.dumps(server_ids),
status="running",
operator=operator,
)
execution = await self.execution_repo.create(execution)
# Dispatch to agents concurrently (max 10 at a time)
results: Dict[str, dict] = {}
semaphore = asyncio.Semaphore(10)
async def _exec_on_server(server_id: int):
server = await self.server_repo.get_by_id(server_id)
if not server or not server.is_online:
results[str(server_id)] = {
"status": "skipped",
"stdout": "",
"stderr": f"Server offline or not found (ID={server_id})",
"exit_code": -1,
}
return
async with semaphore:
try:
result = await self._call_agent_exec(
server.domain, server.agent_port, server.agent_api_key,
resolved_command, timeout,
)
results[str(server_id)] = result
except Exception as e:
results[str(server_id)] = {
"status": "error",
"stdout": "",
"stderr": str(e),
"exit_code": -1,
}
tasks = [_exec_on_server(sid) for sid in server_ids]
await asyncio.gather(*tasks)
# Determine overall status
failed_count = sum(1 for r in results.values() if r.get("exit_code", -1) != 0)
status = "completed" if failed_count == 0 else "failed" if failed_count == len(server_ids) else "partial"
# Update execution record
execution = await self.execution_repo.update_status(
execution.id, status, json.dumps(results)
)
await self._audit(
"execute_command", "script_execution", execution.id,
f"Executed on {len(server_ids)} servers: {status} ({failed_count} failed)",
)
return execution
async def get_execution(self, id: int) -> Optional[ScriptExecution]:
return await self.execution_repo.get_by_id(id)
# ── DB Credential Management ──
async def list_credentials(self) -> List[DbCredential]:
return await self.credential_repo.get_all()
async def create_credential(self, credential: DbCredential) -> DbCredential:
from server.infrastructure.database.crypto import encrypt_value
credential.encrypted_password = encrypt_value(credential.encrypted_password)
created = await self.credential_repo.create(credential)
await self._audit("create_credential", "db_credential", created.id, f"Created credential: {credential.name}")
return created
async def delete_credential(self, id: int) -> bool:
result = await self.credential_repo.delete(id)
if result:
await self._audit("delete_credential", "db_credential", id, f"Deleted credential ID: {id}")
return result
async def test_credential_connection(self, credential: DbCredential) -> dict:
"""Test database connection using provided credentials (before saving)"""
# This runs a simple connectivity test via Agent /api/exec
# e.g., mysql -h $host -u $user -p'$pass' -e 'SELECT 1'
from server.infrastructure.database.crypto import decrypt_value
password = credential.encrypted_password # Not yet encrypted if testing before save
if credential.id:
password = decrypt_value(credential.encrypted_password)
test_cmd = f"mysql -h {credential.host} -P {credential.port} -u {credential.username} -p'{password}' -e 'SELECT 1' 2>/dev/null && echo 'OK' || echo 'FAIL'"
# Pick any online server to test from (or the Nexus server itself)
return {"status": "test_command_ready", "command": test_cmd}
# ── Private helpers ──
async def _substitute_db_vars(self, command: str, credential_id: Optional[int]) -> str:
"""Replace $DB_USER/$DB_PASS/$DB_HOST/$DB_PORT/$DB_NAME in command"""
if not credential_id:
return command
credential = await self.credential_repo.get_by_id(credential_id)
if not credential:
return command
from server.infrastructure.database.crypto import decrypt_value
password = decrypt_value(credential.encrypted_password)
replacements = {
"$DB_USER": credential.username,
"$DB_PASS": password,
"$DB_HOST": credential.host,
"$DB_PORT": str(credential.port),
"$DB_NAME": credential.database or "",
}
for var, value in replacements.items():
command = command.replace(var, value)
return command
async def _call_agent_exec(
self, host: str, port: int, api_key: str, command: str, timeout: int = 30,
) -> dict:
"""Call Agent /api/exec endpoint to run a command on a remote server"""
url = f"http://{host}:{port}/api/exec"
headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
payload = {"command": command, "timeout": timeout}
async with httpx.AsyncClient(timeout=timeout + 5) as client:
try:
resp = await client.post(url, json=payload, headers=headers)
if resp.status_code == 200:
data = resp.json()
return {
"status": data.get("status", "unknown"),
"stdout": data.get("stdout", ""),
"stderr": data.get("stderr", ""),
"exit_code": data.get("exit_code", -1),
}
else:
return {
"status": "error",
"stdout": "",
"stderr": f"Agent returned HTTP {resp.status_code}",
"exit_code": -1,
}
except httpx.TimeoutException:
return {
"status": "timeout",
"stdout": "",
"stderr": f"Agent timeout after {timeout}s",
"exit_code": -1,
}
except httpx.ConnectError:
return {
"status": "connection_error",
"stdout": "",
"stderr": f"Cannot connect to Agent at {host}:{port}",
"exit_code": -1,
}
async def _audit(self, action: str, target_type: str, target_id: int, detail: str):
"""Write audit log entry"""
log = AuditLog(
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
)
await self.audit_repo.create(log)
@@ -0,0 +1,95 @@
"""Nexus — Server Service (Business logic for server management)
Application layer — orchestrates Repository + SSH pool + Redis cache.
"""
import asyncio
import json
import logging
from typing import Optional, List, Dict
from server.domain.models import Server, SyncLog
from server.domain.repositories import ServerRepository, SyncLogRepository
from server.infrastructure.ssh.pool import SSHConfig, exec_command
from server.infrastructure.redis.client import get_redis
logger = logging.getLogger("nexus.server_service")
class ServerService:
"""Business logic for server CRUD, health check, and push operations"""
def __init__(self, server_repo: ServerRepository, sync_log_repo: SyncLogRepository):
self.server_repo = server_repo
self.sync_log_repo = sync_log_repo
async def list_servers(self, category: Optional[str] = None) -> List[Server]:
"""List all servers, optionally filtered by category"""
if category:
return await self.server_repo.get_by_category(category)
return await self.server_repo.get_all()
async def get_server(self, id: int) -> Optional[Server]:
return await self.server_repo.get_by_id(id)
async def create_server(self, server: Server) -> Server:
return await self.server_repo.create(server)
async def update_server(self, server: Server) -> Server:
return await self.server_repo.update(server)
async def delete_server(self, id: int) -> bool:
return await self.server_repo.delete(id)
async def update_heartbeat(self, id: int, data: dict) -> None:
"""Process Agent heartbeat data"""
is_online = data.get("is_online", True)
system_info = json.dumps(data.get("system_info", {}))
agent_version = data.get("agent_version", "")
await self.server_repo.update_heartbeat(id, is_online, system_info, agent_version)
async def check_all_servers(self, server_ids: List[int]) -> Dict[int, dict]:
"""Check health of multiple servers via Agent API (no SSH)"""
results = {}
# TODO: Use httpx to call each Agent's /health endpoint
for id in server_ids:
server = await self.server_repo.get_by_id(id)
if server and server.is_online:
results[id] = {"status": "online", "system_info": json.loads(server.system_info or "{}")}
elif server:
results[id] = {"status": "offline"}
else:
results[id] = {"status": "not_found"}
return results
async def push_to_servers(
self,
server_ids: List[int],
source_path: str,
sync_mode: str = "incremental",
operator: str = "admin",
) -> Dict[int, SyncLog]:
"""Push files to multiple servers in batches"""
results = {}
servers = []
for id in server_ids:
server = await self.server_repo.get_by_id(id)
if server:
servers.append(server)
# TODO: Implement batch push with asyncio.Semaphore(10)
# batch_size=50, interval=30s
for server in servers:
target_path = server.target_path or f"/www/wwwroot/"
log = SyncLog(
server_id=server.id,
source_path=source_path,
target_path=target_path,
trigger_type="batch",
operator=operator,
status="pending",
sync_mode=sync_mode,
)
log = await self.sync_log_repo.create(log)
results[server.id] = log
return results
@@ -0,0 +1,288 @@
"""Nexus — Sync Service (Batch push engine with rsync + Agent coordination)
Application layer — orchestrates Server Repository + SSH pool + Redis progress + WebSocket broadcast.
"""
import asyncio
import json
import logging
import tempfile
import os
from typing import Optional, List, Dict
from server.domain.models import Server, SyncLog, AuditLog
from server.domain.repositories import (
ServerRepository, SyncLogRepository, AuditLogRepository,
PushRetryJobRepository,
)
from server.infrastructure.ssh.pool import SSHConfig, exec_command
from server.infrastructure.redis.client import get_redis
logger = logging.getLogger("nexus.sync_service")
# Batch push configuration
DEFAULT_BATCH_SIZE = 50 # Servers per batch
DEFAULT_CONCURRENCY = 10 # Max concurrent SSH per batch
DEFAULT_BATCH_INTERVAL = 30 # Seconds between batches
DEFAULT_TIMEOUT = 300 # 5 minutes per server
class SyncService:
"""Business logic for batch file push with rsync and progress tracking"""
def __init__(
self,
server_repo: ServerRepository,
sync_log_repo: SyncLogRepository,
audit_repo: AuditLogRepository,
retry_repo: PushRetryJobRepository,
):
self.server_repo = server_repo
self.sync_log_repo = sync_log_repo
self.audit_repo = audit_repo
self.retry_repo = retry_repo
async def batch_push(
self,
server_ids: List[int],
source_path: str,
sync_mode: str = "incremental",
operator: str = "admin",
batch_size: int = DEFAULT_BATCH_SIZE,
concurrency: int = DEFAULT_CONCURRENCY,
batch_interval: int = DEFAULT_BATCH_INTERVAL,
) -> Dict[int, SyncLog]:
"""Push files to multiple servers in batches with concurrency control.
Flow:
1. Divide server_ids into batches (50-100 per batch)
2. Each batch: up to 10 concurrent rsync connections
3. Batch interval: 30 seconds between batches
4. Failed servers: add to retry queue (max 100 retries)
5. Progress tracked in Redis for WebSocket broadcast
"""
results: Dict[int, SyncLog] = {}
servers = []
# Resolve all servers
for sid in server_ids:
server = await self.server_repo.get_by_id(sid)
if server:
servers.append(server)
else:
logger.warning(f"Server ID {sid} not found, skipping")
# Divide into batches
batches = [servers[i:i + batch_size] for i in range(0, len(servers), batch_size)]
total = len(servers)
completed = 0
redis = await get_redis()
# Initialize progress in Redis
if redis:
progress_key = f"sync:progress:{operator}:{int(asyncio.get_event_loop().time())}"
await redis.set(progress_key, json.dumps({
"total": total,
"completed": 0,
"failed": 0,
"current_batch": 0,
"total_batches": len(batches),
"status": "running",
}))
for batch_idx, batch in enumerate(batches):
logger.info(f"Batch {batch_idx + 1}/{len(batches)}: pushing to {len(batch)} servers")
# Update progress
if redis:
await redis.set(progress_key, json.dumps({
"total": total,
"completed": completed,
"failed": sum(1 for r in results.values() if r.status == "failed"),
"current_batch": batch_idx + 1,
"total_batches": len(batches),
"status": "running",
}))
# Execute batch with concurrency control
semaphore = asyncio.Semaphore(concurrency)
batch_tasks = []
async def _push_to_server(server: Server):
async with semaphore:
log = await self._push_single(server, source_path, sync_mode, operator)
results[server.id] = log
if log.status == "success":
completed += 1
elif log.status == "failed":
# Add to retry queue
await self._add_retry(server, source_path, operator)
for server in batch:
batch_tasks.append(_push_to_server(server))
await asyncio.gather(*batch_tasks)
# Batch interval (pause between batches)
if batch_idx < len(batches) - 1 and batch_interval > 0:
logger.info(f"Batch {batch_idx + 1} complete, waiting {batch_interval}s before next batch")
await asyncio.sleep(batch_interval)
# Final progress update
if redis:
await redis.set(progress_key, json.dumps({
"total": total,
"completed": completed,
"failed": sum(1 for r in results.values() if r.status == "failed"),
"current_batch": len(batches),
"total_batches": len(batches),
"status": "completed",
}))
await self._audit(
"batch_push", "sync", 0,
f"Pushed {source_path} to {total} servers: {completed} success, {total - completed} failed",
)
return results
async def _push_single(
self, server: Server, source_path: str, sync_mode: str, operator: str,
) -> SyncLog:
"""Push files to a single server via rsync-over-SSH"""
target_path = server.target_path or "/www/wwwroot/"
# Build rsync flags based on sync_mode
rsync_flags = self._rsync_flags(sync_mode)
# Create sync log
log = SyncLog(
server_id=server.id,
source_path=source_path,
target_path=target_path,
trigger_type="batch",
operator=operator,
status="running",
sync_mode=sync_mode,
)
log = await self.sync_log_repo.create(log)
start_time = __import__("datetime").datetime.utcnow()
try:
# Build SSH config from server model
ssh_config = SSHConfig.from_server(server)
# Build rsync command
# For SSH Key: use in-memory key written to temp file
key_file = None
ssh_opts = "-o StrictHostKeyChecking=no"
if server.ssh_key_configured and server.ssh_key_private:
# Write decrypted key to temp file (600 permission)
from server.infrastructure.database.crypto import decrypt_value
key_content = decrypt_value(server.ssh_key_private)
key_file = tempfile.NamedTemporaryFile(mode='w', suffix='_key', delete=False)
key_file.write(key_content)
key_file.close()
os.chmod(key_file.name, 0o600)
ssh_opts += f" -i {key_file.name}"
elif server.auth_method == "password" and server.password:
# Use sshpass with environment variable (not -p flag)
# sshpass -e SSHPASS=<password> rsync -e "sshpass -e ssh ..."
from server.infrastructure.database.crypto import decrypt_value
password = decrypt_value(server.password)
ssh_opts = f"sshpass -e ssh {ssh_opts}"
rsync_cmd = f"rsync {rsync_flags} -e 'ssh -p {server.port} {ssh_opts}' {source_path} {server.username}@{server.domain}:{target_path}"
# Execute rsync via SSH
result = await exec_command(ssh_config, rsync_cmd, timeout=DEFAULT_TIMEOUT)
# Clean up temp key file
if key_file:
try:
os.unlink(key_file.name)
except OSError:
pass
duration = int((__import__("datetime").datetime.utcnow() - start_time).total_seconds())
# Parse rsync output for file statistics
stdout = result.get("stdout", "")
files_transferred = self._parse_rsync_transferred(stdout)
if result.get("exit_code", -1) == 0:
log = await self.sync_log_repo.update_status(
log.id, "success",
files_transferred=files_transferred,
duration_seconds=duration,
diff_summary=stdout[:2000],
finished_at=__import__("datetime").datetime.utcnow(),
)
else:
log = await self.sync_log_repo.update_status(
log.id, "failed",
error_message=result.get("stderr", "Unknown error")[:2000],
duration_seconds=duration,
finished_at=__import__("datetime").datetime.utcnow(),
)
except Exception as e:
logger.error(f"Push to server {server.id} ({server.name}) failed: {e}")
log = await self.sync_log_repo.update_status(
log.id, "failed",
error_message=str(e)[:2000],
finished_at=__import__("datetime").datetime.utcnow(),
)
return log
async def get_sync_logs(self, server_id: int, limit: int = 50) -> List[SyncLog]:
return await self.sync_log_repo.get_by_server_id(server_id, limit)
async def _add_retry(self, server: Server, source_path: str, operator: str):
"""Add failed push to retry queue"""
from server.domain.models import PushRetryJob
from datetime import datetime, timedelta
job = PushRetryJob(
server_id=server.id,
server_name=server.name,
operator=operator,
source_path=source_path,
target_path=server.target_path or "/www/wwwroot/",
status="pending",
next_retry_at=datetime.utcnow() + timedelta(minutes=5),
)
await self.retry_repo.create(job)
# ── Private helpers ──
def _rsync_flags(self, sync_mode: str) -> str:
"""Build rsync flags based on sync mode"""
base = "-avz"
mode_flags = {
"incremental": base, # Default: only sync modified files
"full": f"{base} --delete", # Sync + delete extraneous files
"overwrite": f"{base} --inplace", # Direct overwrite without temp files
"checksum": f"{base} --checksum", # Checksum-based comparison
}
return mode_flags.get(sync_mode, base)
def _parse_rsync_transferred(self, stdout: str) -> int:
"""Parse rsync output to count transferred files"""
# rsync -v output contains lines like: "sent X bytes received Y bytes Z bytes/sec"
# and a summary line with file counts
for line in stdout.split("\n"):
if "Number of regular files transferred:" in line:
try:
return int(line.split(":")[1].strip())
except (ValueError, IndexError):
pass
# Fallback: count non-summary lines
return 0
async def _audit(self, action: str, target_type: str, target_id: int, detail: str):
log = AuditLog(action=action, target_type=target_type, target_id=target_id, detail=detail)
await self.audit_repo.create(log)
+67 -2
View File
@@ -3,8 +3,12 @@ Protocol-based interfaces for dependency inversion.
Concrete implementations in infrastructure/database/.
"""
from typing import Protocol, Optional, List
from server.domain.models import Server, SyncLog, Script, ScriptExecution, DbCredential
from typing import Protocol, Optional, List, Dict
from server.domain.models import (
Server, SyncLog, Script, ScriptExecution, DbCredential,
Admin, LoginAttempt, Setting, PasswordPreset,
PushSchedule, PushRetryJob, AuditLog,
)
class ServerRepository(Protocol):
@@ -17,6 +21,7 @@ class ServerRepository(Protocol):
async def create(self, server: Server) -> Server: ...
async def update(self, server: Server) -> Server: ...
async def delete(self, id: int) -> bool: ...
async def update_heartbeat(self, id: int, is_online: bool, system_info: str, agent_version: str) -> None: ...
class SyncLogRepository(Protocol):
@@ -27,6 +32,66 @@ class SyncLogRepository(Protocol):
async def update_status(self, id: int, status: str, **kwargs) -> SyncLog: ...
class AdminRepository(Protocol):
"""Admin user data access abstract interface"""
async def get_by_id(self, id: int) -> Optional[Admin]: ...
async def get_by_username(self, username: str) -> Optional[Admin]: ...
async def create(self, admin: Admin) -> Admin: ...
async def update(self, admin: Admin) -> Admin: ...
class LoginAttemptRepository(Protocol):
"""Login attempt data access abstract interface"""
async def create(self, attempt: LoginAttempt) -> LoginAttempt: ...
async def count_recent_failures(self, username: str, ip_address: str, minutes: int = 15) -> int: ...
class SettingRepository(Protocol):
"""System setting data access abstract interface"""
async def get(self, key: str) -> Optional[str]: ...
async def set(self, key: str, value: str) -> Setting: ...
async def get_all(self) -> List[Setting]: ...
class PasswordPresetRepository(Protocol):
"""Password preset data access abstract interface"""
async def get_by_id(self, id: int) -> Optional[PasswordPreset]: ...
async def get_all(self) -> List[PasswordPreset]: ...
async def create(self, preset: PasswordPreset) -> PasswordPreset: ...
async def delete(self, id: int) -> bool: ...
class PushScheduleRepository(Protocol):
"""Push schedule data access abstract interface"""
async def get_by_id(self, id: int) -> Optional[PushSchedule]: ...
async def get_all(self) -> List[PushSchedule]: ...
async def get_enabled(self) -> List[PushSchedule]: ...
async def create(self, schedule: PushSchedule) -> PushSchedule: ...
async def update(self, schedule: PushSchedule) -> PushSchedule: ...
async def delete(self, id: int) -> bool: ...
class PushRetryJobRepository(Protocol):
"""Push retry job data access abstract interface"""
async def get_pending(self, limit: int = 100) -> List[PushRetryJob]: ...
async def create(self, job: PushRetryJob) -> PushRetryJob: ...
async def update_status(self, id: int, status: str, **kwargs) -> PushRetryJob: ...
class AuditLogRepository(Protocol):
"""Audit log data access abstract interface"""
async def create(self, log: AuditLog) -> AuditLog: ...
async def get_recent(self, limit: int = 200) -> List[AuditLog]: ...
async def get_by_action(self, action: str, limit: int = 50) -> List[AuditLog]: ...
class ScriptRepository(Protocol):
"""Script library data access abstract interface"""
@@ -0,0 +1,54 @@
"""Nexus — Admin & LoginAttempt Repository (Async SQLAlchemy)"""
from typing import Optional, List
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Admin, LoginAttempt
class AdminRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, id: int) -> Optional[Admin]:
result = await self.session.execute(select(Admin).where(Admin.id == id))
return result.scalar_one_or_none()
async def get_by_username(self, username: str) -> Optional[Admin]:
result = await self.session.execute(select(Admin).where(Admin.username == username))
return result.scalar_one_or_none()
async def create(self, admin: Admin) -> Admin:
self.session.add(admin)
await self.session.commit()
await self.session.refresh(admin)
return admin
async def update(self, admin: Admin) -> Admin:
await self.session.commit()
await self.session.refresh(admin)
return admin
class LoginAttemptRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, attempt: LoginAttempt) -> LoginAttempt:
self.session.add(attempt)
await self.session.commit()
await self.session.refresh(attempt)
return attempt
async def count_recent_failures(self, username: str, ip_address: str, minutes: int = 15) -> int:
from datetime import datetime, timedelta
cutoff = datetime.utcnow() - timedelta(minutes=minutes)
result = await self.session.execute(
select(func.count(LoginAttempt.id))
.where(LoginAttempt.username == username)
.where(LoginAttempt.ip_address == ip_address)
.where(LoginAttempt.success == False)
.where(LoginAttempt.attempted_at >= cutoff)
)
return result.scalar_one() or 0
@@ -0,0 +1,33 @@
"""Nexus — AuditLog Repository (Async SQLAlchemy)"""
from typing import List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import AuditLog
class AuditLogRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, log: AuditLog) -> AuditLog:
self.session.add(log)
await self.session.commit()
await self.session.refresh(log)
return log
async def get_recent(self, limit: int = 200) -> List[AuditLog]:
result = await self.session.execute(
select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)
)
return list(result.scalars().all())
async def get_by_action(self, action: str, limit: int = 50) -> List[AuditLog]:
result = await self.session.execute(
select(AuditLog)
.where(AuditLog.action == action)
.order_by(AuditLog.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
@@ -0,0 +1,34 @@
"""Nexus — DbCredential Repository (Async SQLAlchemy)"""
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import DbCredential
class DbCredentialRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, id: int) -> Optional[DbCredential]:
result = await self.session.execute(select(DbCredential).where(DbCredential.id == id))
return result.scalar_one_or_none()
async def get_all(self) -> List[DbCredential]:
result = await self.session.execute(select(DbCredential).order_by(DbCredential.id))
return list(result.scalars().all())
async def create(self, credential: DbCredential) -> DbCredential:
self.session.add(credential)
await self.session.commit()
await self.session.refresh(credential)
return credential
async def delete(self, id: int) -> bool:
credential = await self.get_by_id(id)
if credential:
await self.session.delete(credential)
await self.session.commit()
return True
return False
@@ -0,0 +1,34 @@
"""Nexus — PasswordPreset Repository (Async SQLAlchemy)"""
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import PasswordPreset
class PasswordPresetRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, id: int) -> Optional[PasswordPreset]:
result = await self.session.execute(select(PasswordPreset).where(PasswordPreset.id == id))
return result.scalar_one_or_none()
async def get_all(self) -> List[PasswordPreset]:
result = await self.session.execute(select(PasswordPreset).order_by(PasswordPreset.id))
return list(result.scalars().all())
async def create(self, preset: PasswordPreset) -> PasswordPreset:
self.session.add(preset)
await self.session.commit()
await self.session.refresh(preset)
return preset
async def delete(self, id: int) -> bool:
preset = await self.get_by_id(id)
if preset:
await self.session.delete(preset)
await self.session.commit()
return True
return False
@@ -0,0 +1,76 @@
"""Nexus — PushSchedule & PushRetryJob Repository (Async SQLAlchemy)"""
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import PushSchedule, PushRetryJob
class PushScheduleRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, id: int) -> Optional[PushSchedule]:
result = await self.session.execute(select(PushSchedule).where(PushSchedule.id == id))
return result.scalar_one_or_none()
async def get_all(self) -> List[PushSchedule]:
result = await self.session.execute(select(PushSchedule).order_by(PushSchedule.id))
return list(result.scalars().all())
async def get_enabled(self) -> List[PushSchedule]:
result = await self.session.execute(
select(PushSchedule).where(PushSchedule.enabled == True).order_by(PushSchedule.id)
)
return list(result.scalars().all())
async def create(self, schedule: PushSchedule) -> PushSchedule:
self.session.add(schedule)
await self.session.commit()
await self.session.refresh(schedule)
return schedule
async def update(self, schedule: PushSchedule) -> PushSchedule:
await self.session.commit()
await self.session.refresh(schedule)
return schedule
async def delete(self, id: int) -> bool:
schedule = await self.get_by_id(id)
if schedule:
await self.session.delete(schedule)
await self.session.commit()
return True
return False
class PushRetryJobRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_pending(self, limit: int = 100) -> List[PushRetryJob]:
result = await self.session.execute(
select(PushRetryJob)
.where(PushRetryJob.status == "pending")
.order_by(PushRetryJob.created_at)
.limit(limit)
)
return list(result.scalars().all())
async def create(self, job: PushRetryJob) -> PushRetryJob:
self.session.add(job)
await self.session.commit()
await self.session.refresh(job)
return job
async def update_status(self, id: int, status: str, **kwargs) -> PushRetryJob:
job = await self.session.get(PushRetryJob, id)
if job:
job.status = status
for key, value in kwargs.items():
if hasattr(job, key):
setattr(job, key, value)
await self.session.commit()
await self.session.refresh(job)
return job
@@ -0,0 +1,71 @@
"""Nexus — Script & ScriptExecution Repository (Async SQLAlchemy)"""
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Script, ScriptExecution
class ScriptRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, id: int) -> Optional[Script]:
result = await self.session.execute(select(Script).where(Script.id == id))
return result.scalar_one_or_none()
async def get_all(self) -> List[Script]:
result = await self.session.execute(select(Script).order_by(Script.id))
return list(result.scalars().all())
async def get_by_category(self, category: str) -> List[Script]:
result = await self.session.execute(
select(Script).where(Script.category == category).order_by(Script.id)
)
return list(result.scalars().all())
async def create(self, script: Script) -> Script:
self.session.add(script)
await self.session.commit()
await self.session.refresh(script)
return script
async def update(self, script: Script) -> Script:
await self.session.commit()
await self.session.refresh(script)
return script
async def delete(self, id: int) -> bool:
script = await self.get_by_id(id)
if script:
await self.session.delete(script)
await self.session.commit()
return True
return False
class ScriptExecutionRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, id: int) -> Optional[ScriptExecution]:
result = await self.session.execute(
select(ScriptExecution).where(ScriptExecution.id == id)
)
return result.scalar_one_or_none()
async def create(self, execution: ScriptExecution) -> ScriptExecution:
self.session.add(execution)
await self.session.commit()
await self.session.refresh(execution)
return execution
async def update_status(self, id: int, status: str, results: str) -> ScriptExecution:
execution = await self.session.get(ScriptExecution, id)
if execution:
execution.status = status
execution.results = results
await self.session.commit()
await self.session.refresh(execution)
return execution
@@ -0,0 +1,72 @@
"""Nexus — Server Repository (Async SQLAlchemy implementation)
Concrete implementation of ServerRepository protocol.
"""
from typing import Optional, List
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Server
class ServerRepositoryImpl:
"""Async SQLAlchemy implementation of Server data access"""
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, id: int) -> Optional[Server]:
result = await self.session.execute(select(Server).where(Server.id == id))
return result.scalar_one_or_none()
async def get_all(self) -> List[Server]:
result = await self.session.execute(select(Server).order_by(Server.id))
return list(result.scalars().all())
async def get_by_category(self, category: str) -> List[Server]:
result = await self.session.execute(
select(Server).where(Server.category == category).order_by(Server.id)
)
return list(result.scalars().all())
async def get_online(self) -> List[Server]:
result = await self.session.execute(
select(Server).where(Server.is_online == True).order_by(Server.id)
)
return list(result.scalars().all())
async def create(self, server: Server) -> Server:
self.session.add(server)
await self.session.commit()
await self.session.refresh(server)
return server
async def update(self, server: Server) -> Server:
await self.session.commit()
await self.session.refresh(server)
return server
async def delete(self, id: int) -> bool:
server = await self.get_by_id(id)
if server:
await self.session.delete(server)
await self.session.commit()
return True
return False
async def update_heartbeat(self, id: int, is_online: bool, system_info: str, agent_version: str) -> None:
"""Update server online status from Agent heartbeat"""
await self.session.execute(
update(Server)
.where(Server.id == id)
.values(
is_online=is_online,
last_heartbeat=datetime.datetime.utcnow(),
system_info=system_info,
agent_version=agent_version,
)
)
await self.session.commit()
import datetime
@@ -0,0 +1,33 @@
"""Nexus — Setting Repository (Async SQLAlchemy)"""
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Setting
class SettingRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get(self, key: str) -> Optional[str]:
result = await self.session.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def set(self, key: str, value: str) -> Setting:
result = await self.session.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = Setting(key=key, value=value)
self.session.add(setting)
await self.session.commit()
await self.session.refresh(setting)
return setting
async def get_all(self) -> List[Setting]:
result = await self.session.execute(select(Setting).order_by(Setting.key))
return list(result.scalars().all())
@@ -0,0 +1,40 @@
"""Nexus — SyncLog Repository (Async SQLAlchemy implementation)"""
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import SyncLog
class SyncLogRepositoryImpl:
"""Async SQLAlchemy implementation of SyncLog data access"""
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_server_id(self, server_id: int, limit: int = 50) -> List[SyncLog]:
result = await self.session.execute(
select(SyncLog)
.where(SyncLog.server_id == server_id)
.order_by(SyncLog.started_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def create(self, log: SyncLog) -> SyncLog:
self.session.add(log)
await self.session.commit()
await self.session.refresh(log)
return log
async def update_status(self, id: int, status: str, **kwargs) -> SyncLog:
log = await self.session.get(SyncLog, id)
if log:
log.status = status
for key, value in kwargs.items():
if hasattr(log, key):
setattr(log, key, value)
await self.session.commit()
await self.session.refresh(log)
return log
+23 -6
View File
@@ -11,6 +11,19 @@ from fastapi.middleware.cors import CORSMiddleware
from server.config import settings
from server.infrastructure.database.session import init_db
# API routes
from server.api.servers import router as servers_router
from server.api.auth import router as auth_router
from server.api.agent import router as agent_router
from server.api.scripts import router as scripts_router
from server.api.settings import (
router as settings_router,
schedule_router,
preset_router,
audit_router,
retry_router,
)
logger = logging.getLogger("nexus")
@@ -49,9 +62,13 @@ app.add_middleware(
allow_headers=["*"],
)
# TODO: Register routers (servers, auth, agent, scripts, credentials)
# from server.api.servers import router as servers_router
# from server.api.auth import router as auth_router
# from server.api.agent import router as agent_router
# from server.api.scripts import router as scripts_router
# from server.api.credentials import router as credentials_router
# Register all routers
app.include_router(servers_router)
app.include_router(auth_router)
app.include_router(agent_router)
app.include_router(scripts_router)
app.include_router(settings_router)
app.include_router(schedule_router)
app.include_router(preset_router)
app.include_router(audit_router)
app.include_router(retry_router)