23 Commits

Author SHA1 Message Date
Your Name 5958c21048 feat: 推送页面 Round 4 — 服务器搜索 / Diff对比 / 排错诊断 / 源路径输入 / 批量重试
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
5 项新功能:
- H2: 服务器搜索过滤 — 目标服务器下拉加搜索框实时过滤
- H3: 推送对比 Diff 视图 — 预览文件列表加 diff 按钮对比本地与远程差异
- H4: 推送排错面板 — 失败行加诊断按钮检查SSH/磁盘/权限
- H5: 源路径直接输入 — 支持直接输入服务器本地路径推送
- H6: 批量重试 — 一键重试所有失败服务器

新增后端端点:
- POST /api/sync/validate-source-path (H5)
- POST /api/sync/diagnose (H4)
- POST /api/sync/file-diff (H3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:29:41 +08:00
Your Name 02423cb803 docs: 推送页面 Round 2 迭代设计文档与 changelog
- 设计文档: 5 项迭代功能(同步说明、在线标识、失败重试、Telegram通知、文件预览)
- 技术方案: 详细实现步骤与文件清单
- Changelog: 功能变更记录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:54:12 +08:00
Your Name 152852e2c3 fix: 会话时效30天 + 多设备同时登录
- access token 从 30 分钟延长到 30 天
- refresh token 从 7 天延长到 30 天
- refresh token 从 MySQL 单字段改为 Redis Set 存储,支持多设备同时登录
- 单设备退出不再踢掉其他设备(只删自己的 Redis hash)
- 改密码/禁用 TOTP 仍会让所有设备掉线(token_version 递增)
- 移除 reuse 误杀逻辑:版本不匹配只拒绝当前 token,不惩罚其他设备

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:52:14 +08:00
Your Name 3c7036babf fix: 补回 servers.html 丢失的检测路径+卸载Agent功能
从 worktree 分支同步完整的 servers.html,补回:
- 批量操作栏"检测路径"按钮(搜索 workerman.bat 自动设置 target_path)
- 批量操作栏"卸载 Agent"按钮
- batchDetectPath() 和 batchUninstallAgent() 函数

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:56:23 +08:00
Your Name 74de9d303e feat: 推送页面 Round 2 迭代 — 同步说明 + 在线标识 + 失败重试 + Telegram通知 + 文件预览
F5: 同步模式详细说明 — 每种模式下方显示说明文字,切换时动态更新
F2: 服务器在线状态标识 — 服务器列表显示🟢/🔴在线/离线标识
F1: 失败自动重试 — 推送失败自动创建重试任务 + 失败行"🔄 重试"按钮
F3: 推送完成 Telegram 通知 — 全成功🟢/部分失败🟡/全失败🔴 三种消息含详情
F4: 文件内容预览 — 文件管理器点击文件名弹出内容预览模态框

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:59:30 +08:00
Your Name ad2e7667d3 Merge branch 'claude/condescending-ishizaka-3cff3a' 2026-05-28 21:02:09 +08:00
Your Name 82c297776a feat: 推送页面 5 项迭代 — WS实时进度 + 分组选择 + 历史增强 + 文件操作 + 推送校验
F1: broadcast_sync_progress() + batch_id + 前端WS逐台更新
F2: get_paginated() platform_id/node_id + /categories端点 + 筛选下拉框
F4: _sync_log_to_dict() 补全字段 + diff_summary捕获 + 可展开详情面板
F7: /local-file-ops端点 + 文件管理器删除/重命名
F8: /verify端点 md5sum对比 + 推送后校验UI

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:01:27 +08:00
Your Name 77b332c037 fix: 远程安装按钮修复 + install.sh sudo/venv兼容 + api_base_url管理
- 修复详情面板"远程安装"按钮始终禁用:s._base_url 改为从新API获取全局API_BASE_URL
- 新增 GET /api/servers/meta/api_base_url 端点
- api_base_url 加入 DB_OVERRIDE_MAP + 启动迁移逻辑
- install.sh 非 root 用户自动 sudo 检测 + NOPASSWD 配置
- 后端 _sudo_wrap 辅助函数应用于 4 个 Agent 操作端点
- install.sh venv 创建加 || true 兼容 set -e(缺 python3-venv 不再直接退出)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:33:19 +08:00
Your Name 97be1fd214 完善审计日志中文化:auth_service/script_service/sync_engine 全部 detail 改中文 + 前端新增 webssh_token/refresh_token_mismatch 等映射 + MCP deploy 修复
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:57:51 +08:00
Your Name 366bdc18fa fix: deploy tool git pull before gate check + use origin/main
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:27:32 +08:00
Your Name 280357d7d7 Merge remote-tracking branch 'origin/claude/condescending-ishizaka-3cff3a' 2026-05-27 02:19:40 +08:00
Your Name fd9b7d85b5 fix: 批量安装按钮检测 + 审计日志中文化 + terminal断开反馈
1. updateBatchBar() hasAgent 判断从 agent_api_key_set 改为 agent_version
2. 所有后端 AuditLog detail 字段统一中文
3. audit.html 操作/目标类型中文映射
4. terminal.html 断开覆盖层 + 重新连接按钮
5. Agent os_release 上报 + 前端系统版本拆分

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:17:35 +08:00
Your Name 8b82d6bab0 Add changelog for fmtTime/venv/batchinstall fixes
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:59:48 +08:00
Your Name 869f64ac18 Fix install.sh venv robustness + batch install API error reporting
install.sh: detect incomplete venv (missing activate), auto-install
python3-venv and retry instead of silently failing.

servers.py batch install: preserve server_name on exception, detect
"Status: FAILED" in stdout, return error details when stderr is empty,
increase SSH timeout to 180s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:53:00 +08:00
Your Name 69056491a7 Fix fmtTime Invalid Date for timezone-aware timestamps (+00:00)
fmtTime() was appending 'Z' to all timestamps, causing Invalid Date
when the timestamp already had a timezone suffix like +00:00.
Now tries new Date(t) first, falls back to new Date(t+'Z').

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:34:51 +08:00
Your Name 02f1fa4ef3 Add .gitattributes + changelog for Agent install fix and deploy
- .gitattributes: enforce LF line endings for .sh/.py to prevent CRLF issues on Linux
- Changelog: document Agent install 404 fix, curl|bash pipe fix, CRLF fix, and successful deployment to both sub-servers

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 00:27:05 +08:00
Your Name eadf7254a1 Merge remote-tracking branch 'origin/claude/condescending-ishizaka-3cff3a' 2026-05-26 22:59:14 +08:00
Your Name 1c70e597a3 Fix Agent install 404 + add command input bar + fix install pipe error hiding
1. Mount /agent/ static files for install.sh/agent.py (was 404)
2. Replace curl|bash pipe with temp-file download to catch curl errors
3. Add command input bar to terminal.html bottom
4. Fix terminal CSS layout for flex with command bar

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:57:32 +08:00
Your Name 867e2aa552 Merge branch 'claude/condescending-ishizaka-3cff3a'
# Conflicts:
#	server/infrastructure/database/setting_repo.py
2026-05-26 21:58:38 +08:00
Your Name a2d392dae7 fix: WebSSH terminal echo delay — replace readline() with read()
asyncssh's async-for on shell.stdout internally calls readline()
which buffers until a newline is found. For interactive terminal
use, single-character echo never contains a newline, so all
typed characters were buffered until Enter was pressed.

Fix: use shell.stdout.read(4096) which returns data as soon as
any is available, and set bufsize=4096 on create_process() to
reduce the internal read buffer from 128KB to 4KB for low-latency
interactive echo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:54:40 +08:00
Your Name c72631a293 merge: 安装脚本+PHP移除+宝塔适配 合并到main
合并 claude/condescending-ishizaka-3cff3a 分支:
- deploy/install.sh: 一键安装脚本(宝塔/标准模式自适应)
- deploy/upgrade.sh + uninstall.sh
- docs/install-guide.md: 完整中文安装教程
- PHP完全移除: install.php删除, config.php→config.json
- crypto.py: 移除PHP AES-CBC兼容, 纯Fernet
- install.py: 宝塔Supervisor路径自适应, Fernet密钥生成
- install.html: Step 5宝塔/标准模式条件渲染
- 44个冲突文件使用worktree分支版本(全部最新代码)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-25 08:25:29 +08:00
Your Name fbf5eadcf2 fix(security): R2+R3 全面代码审计修复 — 3 CRITICAL / 9 HIGH / 15 MEDIUM / 10 LOW
CRITICAL:
- C-1: sync_service.py rsync命令shlex.quote防Shell注入回归
- C-2: agent.py exec端点危险命令正则拦截+compare_digest+僵尸进程修复
- C-3: WebSSH JWT token绑定server_id防IDOR

HIGH:
- H-1: 所有PUT/POST端点setattr改为字段白名单防任意字段注入
- H-2: settings/assets/scripts端点添加JWT认证
- H-3: refresh_token invalidate-then-generate防并发重放
- H-4: servers.py Redis pipeline解决N+1查询
- H-7: settings API Key/Secret掩码+禁止修改
- H-8: TOTP暴力破解速率限制(5次/5分钟)
- H-9: get_optional_admin改用request-scoped session

MEDIUM:
- M-2: X-Real-IP优先+X-Forwarded-For取末值防IP伪造
- M-4: asyncssh异常信息脱敏(只含server_id)
- M-5: 分页limit上限(500/200/500/500)
- M-9: API层Redis依赖抽象至dependencies.py
- M-10: WebSocket ConnectionManager多worker Redis聚合
- M-13: login_attempts复合索引
- M-14: ServerRepository.get_by_ids批量查询
- M-15: API错误消息统一中文

R3追加:
- sysctl命令注入防护+regex验证
- $DB_PASS明文存储→masked_command双命令方案
- MYSQL_PWD环境变量替代-p flag
- health端点拆分(公开/需认证)
- repo层**kwargs→字段白名单
- 全局str(e)信息泄露修复(API/Telegram/WebSocket)
- SSRF私有IP拦截
- Nginx HTTPS配置+DB备份脚本
- 前端CDN SRI哈希+XSS修复
- __import__替换为正常import

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:58:33 +08:00
Your Name 8c8d2a74d5 docs: add Vercel-style documentation browser
Tailwind CSS + Alpine.js single-page HTML doc viewer with:
- 274 documents embedded (270 md + 4 other file types)
- Dark/light mode with localStorage persistence
- Sidebar navigation with grouped collapsible sections
- Full-text search (filename + content)
- Auto-generated TOC for each document
- Terminal-style code blocks with language labels
- Mobile responsive with slide-out sidebar
- rebuild: python docs/build_html.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 07:26:31 +08:00
66 changed files with 5594 additions and 548 deletions
+9
View File
@@ -0,0 +1,9 @@
# Shell scripts must always use LF line endings (CRLF breaks bash execution on Linux)
*.sh text eol=lf
# Python files should also use LF
*.py text eol=lf
# Windows batch files keep CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
+5
View File
@@ -12,6 +12,8 @@ build/
.install_locked
.install_state.json
SECRETS.md
*.pem
.env.*
.venv/
venv/
ENV/
@@ -54,6 +56,9 @@ web/uploads/
web/data/config.php
web/data/*.db
# Backups
backups/
# Deploy
deploy/*.key
@@ -0,0 +1,74 @@
# Audit: 远程安装按钮修复 + api_base_url 端点
**Date**: 2026-05-27
**Scope**: servers.html detail panel "远程安装" button fix + backend api_base_url exposure
## Step 1: Register Changed Files
| # | File | Change Type |
|---|------|-------------|
| 1 | `server/api/servers.py` | NEW endpoint `GET /meta/api_base_url` |
| 2 | `server/config.py` | DB_OVERRIDE_MAP + `ensure_api_base_url_in_db()` |
| 3 | `server/main.py` | Startup migration call |
| 4 | `server/api/install.py` | settings_kv add api_base_url |
| 5 | `web/app/servers.html` | loadApiBaseUrl() + base_url_conf fix |
| 6 | `web/app/settings.html` | infra section add api_base_url |
## Step 2: Full Read
All files read and verified in this session.
## Step 3: Rule Scan (H = High/Medium/Low)
| # | Rule | File | Line | Finding | Severity |
|---|------|------|------|---------|----------|
| H1 | Auth required on new endpoint | servers.py | 229-235 | `Depends(get_current_admin)` present | ✅ PASS |
| H2 | No sensitive data exposure | servers.py | 235 | Returns `settings.API_BASE_URL` (public URL) | ✅ PASS |
| H3 | SQL injection in migration | config.py | 197-207 | Uses ORM `session.add()`, not raw SQL | ✅ PASS |
| H4 | Route collision | servers.py | 229 | `/meta/api_base_url` BEFORE `/{id}` (line 585+) | ✅ PASS |
| H5 | Input validation | servers.py | 229-235 | No user input accepted (read-only endpoint) | ✅ PASS |
| H6 | Race condition in migration | config.py | 197-207 | `existing is None` check + commit; concurrent startups may both insert but `ON DUPLICATE KEY` schema handles it — actually this uses ORM add, not upsert. BUT `settings` table has UNIQUE key on `key` column, so duplicate insert would raise IntegrityError caught by `except Exception`. Safe. | ✅ PASS |
| H7 | XSS in frontend | servers.html | 375 | `_apiBaseUrl` used as boolean only (not rendered as HTML) | ✅ PASS |
## Step 4: Closure Table
| H# | Verdict | Evidence |
|----|---------|----------|
| H1 | ✅ Safe | JWT auth via `get_current_admin` |
| H2 | ✅ Safe | API_BASE_URL is public site URL, not secret |
| H3 | ✅ Safe | ORM `session.add()`, no f-string SQL |
| H4 | ✅ Safe | `/meta/api_base_url` defined at line 229, `/{id}` at line 585+ |
| H5 | ✅ Safe | GET endpoint, no user input |
| H6 | ✅ Safe | UNIQUE constraint on `key` column; exception caught and logged |
| H7 | ✅ Safe | `_apiBaseUrl` only used in `!!(_apiBaseUrl)` boolean check |
## Step 5: Entry Points
| Endpoint | Method | Auth | Input |
|----------|--------|------|-------|
| `/api/servers/meta/api_base_url` | GET | JWT | None |
## Step 6: Input Validation
No user input on new endpoint. Read-only.
## Step 7: Sinks
- `settings.API_BASE_URL` → returned as JSON value. No shell, no SQL, no HTML rendering.
## Step 8: Classification
| Category | Items | Status |
|----------|-------|--------|
| Security (12) | Auth, injection, XSS, data exposure | All PASS |
| Logic (8) | Route order, migration race, boolean logic | All PASS |
| Performance (5) | One extra API call on page load (cached), startup migration one-time | PASS |
| Quality (5) | Clean code, no TODOs, proper error handling | PASS |
## DoD
- [x] All HIGH findings have PASS verdict
- [x] No security vulnerabilities introduced
- [x] No TODO/FIXME left
- [x] Syntax checks pass
- [x] Changelog updated
@@ -0,0 +1,95 @@
# 审计记录 — 批量按钮修复 + 审计日志中文化
## 审计信息
- **日期**: 2026-05-27
- **审计人**: Claude (AI)
- **触发原因**: Bug修复 — 批量安装Agent按钮检测逻辑错误 + 审计日志详情统一中文
## 审计范围
| 文件 | 改动 | 状态 |
|------|------|------|
| web/app/servers.html | hasAgent 从 agent_api_key_set 改为 agent_version | ☑ 已审 |
| server/api/auth.py | detail 改中文 | ☑ 已审 |
| server/api/servers.py | detail 改中文 | ☑ 已审 |
| server/api/scripts.py | detail 改中文 | ☑ 已审 |
| server/api/assets.py | detail 改中文 | ☑ 已审 |
| server/api/settings.py | detail 改中文 | ☑ 已审 |
| server/api/webssh.py | detail 改中文 | ☑ 已审 |
| server/api/sync_v2.py | detail 改中文 | ☑ 已审 |
| server/api/agent.py | detail 改中文 | ☑ 已审 |
| server/background/script_execution_flush.py | detail 改中文 | ☑ 已审 |
| web/app/audit.html | 操作/目标类型中文化映射 | ☑ 已审 |
## 审计8步结果
### Step 1: 登记 ✅
1) servers.html updateBatchBar() hasAgent 判断从 agent_api_key_set 改为 agent_version
2) 10个后端文件的 AuditLog detail 字段从英文改为中文
3) audit.html 新增 ACTION_NAMES / TARGET_NAMES 映射,操作列和目标列显示中文
### Step 2: 全文Read ✅
逐文件读取确认所有改动点
### Step 3: 规则扫描H
- H1: `hasAgent=!!s.agent_version` — agent_version 由 Agent 心跳写入(agent_version: "2.0.0"),无 Agent 时为 None — ✅ 逻辑正确
- H2: `agent_api_key_set` 仅在详情面板的 noKey 检测中保留使用 — ✅ 该处语义正确(检测是否生成了 Key,是安装命令的前提条件)
- H3: AuditLog detail 字符串改为中文 — 纯显示文本,用 `textContent` 赋值 — ✅ 无 XSS
- H4: ACTION_NAMES / TARGET_NAMES 映射 — 前端 JS 常量,无用户输入 — ✅ 安全
- H5: `esc()` 函数对所有审计日志字段做 HTML 转义 — ✅ 无 XSS
### Step 4: Closure表
| H | 判定 | 依据 |
|---|------|------|
| H1 | ✅ PASS | agent_version 更准确反映实际安装状态 |
| H2 | ✅ PASS | agent_api_key_set 在安装命令上下文中语义正确 |
| H3 | ✅ PASS | 中文字符串不影响安全性 |
| H4 | ✅ PASS | 前端映射是静态常量 |
| H5 | ✅ PASS | esc() 函数始终生效 |
### Step 5: 入口表
| 入口 | 类型 | 来源 |
|------|------|------|
| updateBatchBar 按钮 | JS逻辑 | 服务器列表 API 数据 |
| AuditLog detail | 数据库写入 | 后端 API handler |
| audit.html 渲染 | DOM渲染 | 审计日志 API 返回 |
### Step 6: 输入→Sink
- `s.agent_version` → 条件判断 → 按钮禁用状态 — ✅ 安全(不经过 DOM)
- 中文 detail 字符串 → MySQL → API → `esc()` → DOM — ✅ 安全
- `ACTION_NAMES[l.action]``esc()` → DOM — ✅ 安全
### Step 7: 归类
```
web/app/servers.html ~830行 1H 0FINDING
server/api/auth.py ~310行 1H 0FINDING
server/api/servers.py ~1100行 1H 0FINDING
server/api/scripts.py ~380行 1H 0FINDING
server/api/assets.py ~230行 1H 0FINDING
server/api/settings.py ~770行 1H 0FINDING
server/api/webssh.py ~360行 1H 0FINDING
server/api/sync_v2.py ~290行 1H 0FINDING
server/api/agent.py ~330行 1H 0FINDING
server/background/script_execution_flush.py ~46行 1H 0FINDING
web/app/audit.html ~155行 1H 0FINDING
────────────────────────────────────────────────────
总计 11文件 5H 0FINDING
```
### Step 8: DoD ✅
- ❌ 明文密码/密钥暴露? — 否
- ❌ 命令注入? — 否
- ❌ SQL注入? — 否
- ❌ XSS? — 否(全部 esc() 转义)
- ❌ 静默吞错? — 否
- ❌ 硬编码? — 否
- ✅ 所有FINDING已修复? — 0 FINDING
## FINDING 列表
## 结论
☑ 审计通过,0 FINDING,可部署
@@ -0,0 +1,76 @@
# 审计记录 — servers.html 系统版本拆分 + 空字段隐藏
## 审计信息
- **日期**: 2026-05-27
- **审计人**: Claude (AI)
- **触发原因**: UX改进 — 系统版本拆为发行版+内核平台,空字段隐藏不显示--
## 审计范围
| 文件 | 行数 | 状态 |
|------|------|------|
| web/app/servers.html | ~750 | ☑ 已审 |
## 审计8步结果
### Step 1: 登记 ✅
改动:1) 系统版本拆为「发行版」(siOSRelease) + 「内核平台」(siPlatform) 两卡片;2) 描述/目标路径/认证方式空值时隐藏整行
### Step 2: 全文Read ✅
全文读取 servers.html,确认所有改动点
### Step 3: 规则扫描H
- H1: `sys.os_release` / `sys.platform` — API返回的只读字符串,用 `textContent` 赋值,不经过 `innerHTML` — ✅ 无XSS
- H2: `s.description` / `s.target_path` / `s.auth_method` — 同上,`textContent` 赋值 — ✅ 无XSS
- H3: `style.display` 控制行显隐 — DOM属性操作,非用户输入 — ✅ 无注入
- H4: `<code id="siTarget">` — 使用语义标签包裹路径,textContent赋值 — ✅ 安全
- H5: grid从4列变5卡片,`lg:grid-cols-4` 自动换行 — ✅ 布局安全
- H6: `flex-wrap gap-x-6 gap-y-2` 替代 `grid-cols-3` — 空值隐藏后自动重排 — ✅ 布局正确
### Step 4: Closure表
| H | 判定 | 依据 |
|---|------|------|
| H1 | ✅ PASS | textContent不解析HTMLXSS不可能 |
| H2 | ✅ PASS | 同H1 |
| H3 | ✅ PASS | style.display值固定为空或none |
| H4 | ✅ PASS | code标签+textContent,安全 |
| H5 | ✅ PASS | 纯CSS布局,无安全影响 |
| H6 | ✅ PASS | flex-wrap自动适配,无安全问题 |
### Step 5: 入口表
| 入口 | 类型 | 来源 |
|------|------|------|
| sysInfoGrid 显示 | DOM渲染 | API返回的system_info JSON |
| sysInfoMeta 显隐 | DOM渲染 | API返回的server字段 |
### Step 6: 输入→Sink
- `sys.os_release``textContent` → DOM文本节点 — ✅ 安全(不经过innerHTML
- `sys.platform``textContent` → DOM文本节点 — ✅ 安全
- `s.description``textContent` → DOM文本节点 — ✅ 安全
- `s.target_path``textContent` → DOM文本节点(code标签)— ✅ 安全
- `s.auth_method` → 条件判断 → `textContent` → DOM文本节点 — ✅ 安全
### Step 7: 归类
```
web/app/servers.html 750行 6H 0FINDING
────────────────────────────────────────
总计 6H 0FINDING
```
### Step 8: DoD ✅
- ❌ 明文密码/密钥暴露? — 否
- ❌ 命令注入? — 否
- ❌ SQL注入? — 否(纯前端)
- ❌ XSS — 否(全部textContent
- ❌ 静默吞错? — 否
- ❌ 硬编码? — 否
- ✅ 所有FINDING已修复? — 0 FINDING
## FINDING 列表
## 结论
☑ 审计通过,0 FINDING,可部署
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Generate nexus-docs.html from template.html + ALL docs/ files.
Usage: python build_html.py
Output: docs/nexus-docs.html (single self-contained HTML file)
Collects: .md .json .js .py .sh .txt .yaml .yml .toml .cfg .ini .conf .env .sql
Excludes: build_html.py, template.html, nexus-docs.html, binary files
"""
import os
import json
DOCS_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATE = os.path.join(DOCS_DIR, "template.html")
OUTPUT = os.path.join(DOCS_DIR, "nexus-docs.html")
# File extensions to include (text/documentation files)
INCLUDE_EXT = {
'.md', '.markdown',
'.json', '.jsonl',
'.js', '.ts', '.mjs',
'.py', '.sh', '.bash',
'.txt', '.csv',
'.yaml', '.yml',
'.toml', '.cfg', '.ini', '.conf',
'.env', '.example',
'.sql',
'.xml', '.html', '.css',
'.gitignore',
}
# Files to always exclude (by basename)
EXCLUDE_FILES = {
'build_html.py',
'template.html',
'nexus-docs.html',
}
# Directories to skip
SKIP_DIRS = {'.git', '__pycache__', 'node_modules', '.venv', 'venv'}
# Group config: (directory_match, display_name, icon)
GROUP_CONFIG = [
("project", "Project", "📋"),
("design", "Design", "🎨"),
("changelog", "Changelog", "📝"),
("reports", "Reports", "📊"),
("team", "Team", "👥"),
("memory", "Memory", "🧠"),
("research", "Research", "🔬"),
("skills", "Skills", ""),
]
# Fallback for unmatched dirs
FALLBACK_ICON = "📄"
def collect_all_files():
"""Walk docs/ and return list of (relative_path, full_path) sorted. Includes all text doc types."""
results = []
for root, dirs, files in os.walk(DOCS_DIR):
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
for f in sorted(files):
if f in EXCLUDE_FILES:
continue
ext = os.path.splitext(f)[1].lower()
if ext in INCLUDE_EXT:
full = os.path.join(root, f)
rel = os.path.relpath(full, DOCS_DIR).replace('\\', '/')
results.append((rel, full))
return results
def group_files(files):
"""Group files by top-level directory using GROUP_CONFIG."""
grouped = {}
for rel, full in files:
top = rel.split('/')[0] if '/' in rel else "Root"
# Match against config
matched = False
for dir_key, display_name, icon in GROUP_CONFIG:
if top == dir_key or top.startswith(dir_key):
key = display_name
if key not in grouped:
grouped[key] = {"icon": icon, "files": []}
grouped[key]["files"].append((rel, full))
matched = True
break
if not matched:
if top not in grouped:
# Find icon from config if partial match
icon = FALLBACK_ICON
for dir_key, _, ic in GROUP_CONFIG:
if dir_key in top:
icon = ic
break
grouped[top] = {"icon": icon, "files": []}
grouped[top]["files"].append((rel, full))
# Sort by GROUP_CONFIG order, then alphabetically for rest
ordered = []
for _, display_name, _ in GROUP_CONFIG:
if display_name in grouped:
ordered.append((display_name, grouped.pop(display_name)))
for k in sorted(grouped.keys()):
ordered.append((k, grouped[k]))
return ordered
def read_file(path):
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
except Exception:
return "*Unable to read file*"
def build_data(groups):
"""Build NAV_GROUPS and CONTENT_MAP JS objects."""
nav_groups = []
content_map = {}
for name, info in groups:
files = []
for rel, full in info["files"]:
# Show full filename as label (with extension for non-md files)
label = rel.split('/')[-1]
# Remove .md extension only for markdown files
if label.endswith('.md'):
label = label[:-3]
files.append({"path": rel, "label": label})
# For non-md files, wrap content in a code block for proper rendering
raw = read_file(full)
ext = os.path.splitext(rel)[1].lower()
if ext == '.md':
content_map[rel] = raw
else:
lang = ext.lstrip('.')
content_map[rel] = f"# `{rel}`\n\n```{lang}\n{raw}\n```"
nav_groups.append({
"name": name,
"icon": info["icon"],
"collapsed": name == "Skills", # Skills collapsed by default
"files": files,
})
return nav_groups, content_map
def generate(nav_groups, content_map):
"""Read template, inject data, return final HTML."""
with open(TEMPLATE, 'r', encoding='utf-8') as f:
template = f.read()
nav_json = json.dumps(nav_groups, ensure_ascii=False, indent=None)
# content_map can be huge — compact serialization
content_json = json.dumps(content_map, ensure_ascii=False, indent=None)
# Inject data before the docApp() function
data_script = f"""<script>
// Auto-generated data — do not edit
const NAV_GROUPS = {nav_json};
const CONTENT_MAP = {content_json};
</script>
"""
# Insert data script right before the docApp function script
placeholder = "// __DATA_PLACEHOLDER__ will be replaced by build_html.py"
if placeholder in template:
html = template.replace(placeholder, f"// Data injected by build_html.py\nconst NAV_GROUPS = {nav_json};\nconst CONTENT_MAP = {content_json};")
else:
# Fallback: insert before closing </body>
html = template.replace("</body>", data_script + "</body>")
return html
def main():
import sys
sys.stdout.reconfigure(encoding='utf-8')
print("Scanning docs/...")
files = collect_all_files()
md_count = sum(1 for f in files if f[0].endswith('.md'))
other_count = len(files) - md_count
print(f" Found {md_count} markdown + {other_count} other = {len(files)} total files")
print("Grouping...")
groups = group_files(files)
for name, info in groups:
print(f" {info['icon']} {name}: {len(info['files'])} files")
print("Reading content & building data...")
nav_groups, content_map = build_data(groups)
print("Generating HTML...")
html = generate(nav_groups, content_map)
with open(OUTPUT, 'w', encoding='utf-8') as f:
f.write(html)
size_mb = os.path.getsize(OUTPUT) / 1024 / 1024
print(f"\nDone: {OUTPUT}")
print(f"Size: {size_mb:.1f} MB")
if __name__ == '__main__':
main()
+31
View File
@@ -0,0 +1,31 @@
# 2026-05-23 — MEDIUM Issue Fixes (Post-Audit Round 2)
## Summary
Fixed all 14+ MEDIUM issues identified in the full codebase audit.
## Changes
### Backend Core
- **WebSocket connection limits**: Added `MAX_CONNECTIONS=500` to ConnectionManager; rejects new connections when limit reached (memory DoS prevention)
- **WebSSH message size limits**: Added `MAX_MSG_SIZE=100KB` and `MAX_DATA_SIZE=10KB` validation before processing WebSocket messages
- **setting_repo SELECT-then-INSERT race**: Replaced TOCTOU pattern with MySQL `ON DUPLICATE KEY UPDATE` for atomic upsert
- **Migration atomicity**: Removed intermediate `session.commit()` calls — single commit at end for all-or-nothing migration
- **SyncLog/ScriptExecution return types**: Fixed `update_status()` return types from non-Optional to `Optional[SyncLog]` / `Optional[ScriptExecution]`
### Security Hardening
- **Nginx rate limiting**: Added `limit_req_zone` (10 req/s per IP, burst 20) for API endpoints
- **X-Forwarded-For IP extraction**: Added `_client_ip()` helper to auth.py that checks `X-Forwarded-For` header first, falls back to `request.client.host`
- **CDN SRI integrity hashes**: Added `integrity` + `crossorigin` attributes to all CDN script/style tags across all 12 HTML pages (tailwindcss, alpinejs, xterm, xterm-addon-fit)
- **Global 401 interceptor**: Added `window.fetch` override on all pages — auto-redirects to login on 401 responses
### Deployment
- **Supervisor stopasgroup**: Added `stopasgroup=true` to ensure SIGINT reaches entire process tree
- **health_monitor.sh hardening**: Added `set -euo pipefail`; wrapped FAIL_FILE read-modify-write in `flock` for atomicity
- **health_monitor.sh fail count**: All 3 fail-count access points now use `flock`-protected critical sections
### Code Quality
- **decrypt_value documentation**: Clarified backward-compat contract; added `try_decrypt_value()` for callers needing explicit success/failure signal
- **Dual DB session deprecation**: Marked `get_async_session()` as deprecated with docstring pointing to middleware-based `request.state.db` pattern
## Files Changed
39 files, 498 insertions, 210 deletions
@@ -0,0 +1,146 @@
# Round 2 全面代码审计修复报告
**日期**: 2026-05-23
**范围**: 全部8类别深度扫描 (功能性缺陷/安全漏洞/代码规范/安全风险/性能/可用性/可扩展性/优化)
## 修复汇总
| 严重度 | 数量 | 状态 |
|--------|------|------|
| CRITICAL | 3 | 全部修复 |
| HIGH | 9 | 全部修复 |
| MEDIUM | 15 | 全部修复 |
| LOW | 10 | 评估后保留/已修复 |
---
## CRITICAL 修复
### C-1: sync_service.py Shell注入回归 (CRITICAL)
- **文件**: `server/application/services/sync_service.py`
- **修复**: 对rsync命令所有动态参数添加 `shlex.quote()` — source_path, target_path, username, domain, key file path
- **附带修复**: sshpass密码通过 `env={"SSHPASS": password}` 传递给子进程;`__import__("datetime")` 替换为正常import
### C-2: agent.py exec端点RCE无防护 (CRITICAL)
- **文件**: `server/api/agent.py`
- **修复**: 添加 `DANGEROUS_PATTERNS` 危险命令正则检测 + 403拦截;API Key比较改为 `secrets.compare_digest()``proc.kill()` 后加 `await proc.wait()` 防僵尸进程;server_id 类型验证
### C-3: WebSSH IDOR — token未绑定server_id (CRITICAL)
- **文件**: `server/api/webssh.py`
- **修复**: 验证JWT token中的server_id与路径参数匹配,不匹配返回4003关闭连接
---
## HIGH 修复
### H-1: PUT/POST端点setattr任意字段注入 (HIGH)
- **文件**: `servers.py`, `settings.py`, `assets.py`, `scripts.py`
- **修复**: 为每个模型定义字段白名单 (SERVER_CREATE_FIELDS, SCHEDULE_CREATE_FIELDS, PLATFORM_CREATE_FIELDS, SCRIPT_CREATE_FIELDS, CREDENTIAL_CREATE_FIELDS等)create端点用 `safe_data = {k:v for k,v in payload.items() if k in WHITELIST}` 过滤;update端点只set白名单内字段
### H-2: settings/assets/scripts端点无JWT认证 (HIGH)
- **文件**: `settings.py`, `assets.py`, `scripts.py`
- **修复**: 所有端点添加 `admin: Admin = Depends(get_current_admin)` 依赖
### H-3: refresh_token并发重放攻击 (HIGH)
- **文件**: `server/application/services/auth_service.py`
- **修复**: refresh时先清空旧token (invalidate-then-generate模式),并发请求第二个会因找不到token而失败
### H-4: servers.py N+1 Redis查询 (HIGH)
- **文件**: `server/api/servers.py`
- **修复**: 使用Redis pipeline批量获取所有heartbeat key,单次round-trip
### H-5: agent.py proc.kill()僵尸进程 (HIGH) — 与C-2合并修复
### H-6: agent.py heartbeat server_id类型验证 (MEDIUM→HIGH) — 与C-2合并修复
### H-7: settings API Key/Secret泄露 (HIGH)
- **文件**: `server/api/settings.py`
- **修复**: 定义 `SENSITIVE_SETTING_KEYS` 集合;list/get返回 `***MASKED***`PUT禁止修改secret_key/encryption_key
### H-8: TOTP暴力破解无速率限制 (HIGH)
- **文件**: `server/application/services/auth_service.py`
- **修复**: 添加内存TOTP速率限制器,5次失败后锁定5分钟
### H-9: get_optional_admin独立session泄漏 (HIGH)
- **文件**: `server/api/auth_jwt.py`
- **修复**: 改用 `_get_request_session(request)` 获取request-scoped session
---
## MEDIUM 修复
### M-2: X-Forwarded-For IP伪造
- **文件**: `server/api/auth.py`
- **修复**: 优先使用X-Real-IPNginx设置);X-Forwarded-For取最后一个值(Nginx追加)
### M-3: asyncssh known_hosts=None — 添加ADR注释说明
- **文件**: `server/infrastructure/ssh/asyncssh_pool.py`
- **修复**: 添加ADR-012注释说明跳过原因(受控服务器环境)
### M-4: asyncssh异常信息泄露服务器地址
- **文件**: `server/infrastructure/ssh/asyncssh_pool.py`
- **修复**: ConnectionError只包含server_id,完整错误写入loggerexec_ssh_command返回通用错误消息
### M-5: 分页limit无上限
- **文件**: `settings.py`, `assets.py`
- **修复**: audit_logs limit上限500ssh_sessions上限200command_logs上限500retry_jobs上限500
### M-9: API层直接import infrastructure层
- **文件**: `servers.py`, `agent.py`
- **修复**: 在 `dependencies.py` 添加 `get_redis_client()` 抽象函数,API层通过此函数获取Redis客户端
### M-10: WebSocket ConnectionManager单进程限制
- **文件**: `server/api/websocket.py`
- **修复**: 添加 `global_client_count()` 方法,通过Redis聚合多worker连接数
### M-11: servers.py Redis overlay逻辑重复
- **文件**: `server/api/servers.py`
- **修复**: 提取 `_overlay_redis_heartbeat()` 辅助函数,list_servers和get_server共用
### M-13: login_attempts表缺少查询索引
- **文件**: `server/domain/models/__init__.py`
- **修复**: 添加 `Index('idx_login_attempts_check', 'username', 'ip_address', 'success', 'attempted_at')`
### M-14: sync_service批量获取server逐个查询
- **文件**: `server/domain/repositories/__init__.py`, `server/infrastructure/database/server_repo.py`, `server/application/services/sync_service.py`
- **修复**: ServerRepository添加 `get_by_ids(ids)` 方法,使用 `IN` 查询一次获取所有server
### M-15: API错误消息中英文混用
- **文件**: 所有API路由文件
- **修复**: 统一为中文错误消息(服务器未找到、平台未找到、脚本未找到、必填等)
---
## 修改文件清单
1. `server/api/servers.py` — setattr白名单+JWT auth+Redis pipeline+overlay去重+中文错误消息
2. `server/api/settings.py` — setattr白名单+JWT auth+敏感设置掩码+分页上限+中文错误消息
3. `server/api/assets.py` — setattr白名单+JWT auth+分页上限+中文错误消息
4. `server/api/scripts.py` — setattr白名单+JWT auth+中文错误消息
5. `server/api/agent.py` — 危险命令检测+compare_digest+僵尸进程+server_id验证+Redis抽象+中文错误消息
6. `server/api/webssh.py` — IDOR server_id绑定
7. `server/api/auth.py` — X-Real-IP优先+X-Forwarded-For取最后一个值
8. `server/api/auth_jwt.py` — get_optional_admin使用request session
9. `server/application/services/sync_service.py` — shlex.quote+sshpass env+__import__替换+批量get_by_ids
10. `server/application/services/auth_service.py` — refresh_token防重放+TOTP速率限制
11. `server/infrastructure/ssh/asyncssh_pool.py` — 错误信息脱敏+ADR注释
12. `server/infrastructure/ssh/pool.py` — exec_command支持env参数
13. `server/infrastructure/database/server_repo.py` — 添加get_by_ids批量查询
14. `server/domain/repositories/__init__.py` — ServerRepository协议添加get_by_ids
15. `server/domain/models/__init__.py` — LoginAttempt添加复合索引
16. `server/api/dependencies.py` — 添加get_redis_client抽象
17. `server/api/websocket.py` — 添加global_client_count多worker支持
18. `server/application/services/sync_engine_v2.py` — sysctl命令注入防护+SFTP错误脱敏
19. `server/application/services/script_service.py` — SSRF私有IP拦截+str(e)脱敏+MYSQL_PWD环境变量
20. `server/api/health.py` — 未认证端点拆分为/health(公开)+/health/detail(需JWT)
21. `server/infrastructure/database/push_schedule_repo.py` — **kwargs setattr改为字段白名单
22. `server/infrastructure/database/sync_log_repo.py` — **kwargs setattr改为字段白名单
23. `server/api/agent.py` — Exception str(e)脱敏
24. `server/background/self_monitor.py` — Telegram消息str(e)脱敏
25. `server/api/webssh.py` — SSH连接/Shell创建错误str(e)脱敏+关闭原因不暴露server_id
26. `server/application/services/sync_service.py` — error_message脱敏+stderr不写入DB
27. `server/infrastructure/database/ssh_session_repo.py` — __import__替换为正常import
28. `server/api/sync_v2.py` — 错误消息统一中文
29. `server/api/servers.py` — _server_to_dict移除username字段(防SSH凭据泄露)
30. `server/infrastructure/ssh/pool.py` — ValueError不暴露host地址
31. `server/infrastructure/database/server_repo.py` — import语句移至文件顶部
@@ -0,0 +1,71 @@
# WebSSH 终端命令输入栏
**日期**: 2026-05-26
**变更摘要**: 为 WebSSH 终端底部新增命令输入栏,避免直接在 xterm 中逐字符输入导致的显示错乱问题
## 动机
1. 用户直接在 xterm 终端中逐字符输入时,SSH echo 与 ANSI prompt 序列交错,导致显示错乱(如 "1111312312oot@mqtele:~#"
2. 根因:逐字符发送 WebSocket 消息时,`read(4096)` 返回的数据可能混合 echo 和 prompt 的 ANSI 片段,时序依赖导致渲染错乱
3. 命令输入栏一次性发送完整命令 + `\r`,完全避免逐字符 echo 交错问题
4. 同时提供 Ctrl+C/D 和 Tab 快捷按钮,方便运维操作
## 变更内容
### web/app/terminal.html — 3处修改
**修改1: CSS 布局调整**
- 旧:`#terminalWrap{height:calc(100vh - 48px)}` — 固定高度,终端占满
- 新:`#terminalWrap{flex:1;min-height:0}` — flex 弹性布局,为底部命令栏留空间
- 全屏样式简化:移除 `height:100vh`flex:1 + fixed inset 自动撑满
**修改2: HTML 结构 — 新增命令输入栏**
- terminalWrap 改为 `flex flex-col` 容器
- `#terminal``flex-1 min-h-0` 占据剩余空间
- 新增 `#cmdBar` 底部栏,包含:
- 命令提示符 ``
- `<input id="cmdInput">` — 命令输入框,monospace 字体
- `发送` 按钮 — 调用 `sendCmd()`
- `⌃C` / `⌃D` / `Tab` 快捷按钮 — 分别调用 `sendCtrl()` / `sendKey()`
**修改3: JavaScript — 命令输入逻辑**
- `cmdHistory` / `cmdHistoryIdx` — 命令历史记录 + 上下箭头导航
- `cmdInput keydown` — Enter 发送、ArrowUp/Down 浏览历史
- `sendCmd()` — 发送完整命令 + `\r`,记入历史,清空输入框
- `sendCtrl(key)` — 发送控制字符(charCode - 96
- `sendKey(key)` — 发送原始按键(如 Tab
## 技术分析
逐字符输入问题:
```
用户输入 'w' → WS send {type:DATA, data:"w"}
→ SSH echo "\033[...w" → read(4096) 可能返回 "w\033[...root@..."
→ xterm 渲染时 ANSI 序列与 echo 字符交错 → 显示错乱
```
命令输入栏解决方案:
```
用户输入 "whoami" → 点发送 → WS send {type:DATA, data:"whoami\r"}
→ SSH 完整回显 "whoami\r\nroot\r\n" → 一次返回 → 正常渲染
```
## 涉及文件
- `web/app/terminal.html` — 终端页面(CSS + HTML + JS 共3处修改)
## 是否需迁移/重启
- 不需重启后端(纯前端改动)
- 不需 DB 迁移
## 验证方式
1. 打开 terminal.html → 底部显示命令输入栏(❯ 提示符 + 输入框 + 发送按钮)
2. 输入 `whoami` → 点发送 → 终端显示 `root`(无显示错乱)
3. 输入 `ls` → Enter 键发送 → 正常显示文件列表
4. ArrowUp → 回显上一条命令 `ls`ArrowDown → 清空
5. 点击 `⌃C` 按钮 → 中断当前命令
6. 点击 `Tab` 按钮 → 触发自动补全
7. 终端 xterm 区域自动占满输入栏上方空间
8. 全屏模式下输入栏仍然可见
@@ -0,0 +1,59 @@
# WebSSH 终端输入回显延迟修复
**日期**: 2026-05-26
**变更摘要**: 修复 WebSSH 终端输入字符不即时显示的问题 — 将 async-for (readline) 改为 read() + 降低 bufsize
## 动机
1. 用户在终端输入字符后,字符不会即时显示(echo 延迟),必须按 Enter 后才一次性出现
2. 根因:`async for data in shell.stdout` 内部调用 `readline()``readuntil('\n')`,会缓冲所有数据直到遇到换行符
3. 交互式终端中,单字符 echo 永远不包含换行符,导致所有输入字符被缓冲直到用户按 Enter
## 变更内容
### server/api/webssh.py — 2处修改
**修改1: `_read_shell_output()` 函数 — 消除 readline 缓冲**
- 旧:`async for data in shell.stdout:` (内部 readline,等换行才 yield
- 新:`while not shell.stdout.at_eof(): data = await shell.stdout.read(4096)` (有数据即返回)
- `read(n)` 使用 `exact=False`,只要缓冲区有数据就立即返回(最多 n 字节),不等换行
**修改2: `create_process()` 调用 — 降低 bufsize**
- 旧:`conn.create_process(term_type="xterm-256color", term_size=(24, 80))` (默认 bufsize=128KB
- 新:`conn.create_process(term_type="xterm-256color", term_size=(24, 80), bufsize=4096)`
- 两处 create_process(主连接 + 失效重试连接)均添加 `bufsize=4096`
- 4KB 缓冲区足够单次交互 echo,同时大幅降低延迟
## 技术分析
asyncssh 数据流路径:
```
async for data in shell.stdout
→ SSHStreamProcess.__aiter__()
→ SSHStreamSession.aiter()
→ readline()
→ readuntil('\n') ← 阻塞直到换行符
```
修复后路径:
```
while not shell.stdout.at_eof():
data = await shell.stdout.read(4096)
→ SSHStreamSession.read(4096, exact=False) ← 有数据即返回
```
## 涉及文件
- `server/api/webssh.py` — WebSSH WebSocket 处理(2处修改)
## 是否需迁移/重启
- 需重启后端(webssh.py 修改)
- 前端无改动
## 验证方式
1. 打开 terminal.html?server_id=8 → WebSocket 保持 OPEN,状态显示「已连接」
2. 输入 `whoami` → 字符即时显示(不等 Enter),按 Enter 后立即显示 `root`
3. 后端日志无 `shell creation failed` 错误
4. 终端交互流畅,无 echo 延迟
@@ -0,0 +1,57 @@
# 2026-05-27 Agent安装修复 + 两台子服务器Agent部署
## 变更摘要
1. 修复 Agent 安装 404 错误(/agent/ 静态文件未挂载)
2. 修复 curl|bash 管道错误隐藏问题
3. 修复 CRLF 导致远程 shell 脚本执行失败
4. 部署 Agent 到两台子服务器
## 动机
Agent 安装命令一直返回 404,因为 FastAPI 未挂载 /agent/ 静态文件路由。
curl|bash 管道模式会隐藏下载失败。
Windows 工作区给 .sh/.py 文件添加了 CRLF,导致 Linux 上 `\r': command not found`
## 涉及文件
### 后端
- `server/main.py` — 新增 `app.mount("/agent", StaticFiles(...))` 挂载 web/agent/ 目录
- `server/api/servers.py` — 3处 `curl ... | bash -s --` 改为 `TMP=$(mktemp) && curl ... -o "$TMP" && bash "$TMP" --`
### 前端/Agent
- `web/agent/install.sh` — CRLF→LF
- `web/agent/agent.py` — CRLF→LF
- `web/agent/agent.sh` — CRLF→LF
### 部署脚本
- `deploy/install.sh` — CRLF→LF
- `deploy/health_monitor.sh` — CRLF→LF
- `deploy/db_backup.sh` — CRLF→LF
### 根目录
- `.gitattributes` — 新增,防止 git 自动转 CRLF*.sh eol=lf, *.py eol=lf
## Agent 部署结果
| 服务器 | IP | server_id | 状态 |
|--------|-----|-----------|------|
| 机器人 | 66.154.115.131 | 8 | ✅ online, agent_version=2.0.0 |
| 冲量银海 | 39.105.228.140 | 9 | ✅ online, agent_version=2.0.0 |
### 冲量银海额外步骤
- 需先 `apt install python3.11-venv`Debian 12 默认不带 python3.11-venv
- install.sh 第2步 `python3 -m venv` 在缺少 ensurepip 时会静默失败,source activate 报错
## 需要迁移/重启
- 是:需重启 Nexus 后端(Supervisor: `supervisorctl restart nexus`
- 是:需 Nginx reload`nginx -s reload`
- 否:无数据库迁移
## 验证方式
1. `curl https://api.synaglobal.vip/agent/install.sh` → 返回脚本内容(非 404
2. Nexus Dashboard 显示 2 台服务器在线
3. `curl https://api.synaglobal.vip/api/servers/8``is_online: true, agent_version: "2.0.0"`
4. `curl https://api.synaglobal.vip/api/servers/9``is_online: true, agent_version: "2.0.0"`
## 已知问题
- install.sh 第2步 venv 创建在缺少 python3-venv 时会静默失败,应加检测
- batch install API 返回 success:false 但无具体错误信息,需改进错误上报
@@ -0,0 +1,55 @@
# 2026-05-27 Agent os_release 上报 + 前端系统版本拆分 + 空字段隐藏
## 变更摘要
1. Agent 心跳和 /health 端点新增 `os_release` 字段,读取 `/etc/os-release` 的 PRETTY_NAME(如 "Ubuntu 24.04 LTS"
2. servers.html 系统信息面板拆分为两卡片:「发行版」显示 os_release,「内核平台」显示 platform
3. servers.html 详情面板描述/目标路径/认证方式空值时隐藏整行,不再显示 `--`
## 动机
1. servers.html 系统版本列显示 `Linux-6.8.0-31-generic-x86_64-with-glibc2.39`(内核字符串),无法直观判断发行版
2. 运维人员需要快速识别服务器是 Ubuntu 24 还是 Debian 12 等
3. 空字段显示 `--` 无意义,应该隐藏整行不占空间
## 涉及文件
### Agent
- `web/agent/agent.py` — 新增 `_os_release()` 函数;health 端点添加 `os_release` 字段;heartbeat payload 添加 `os_release` 字段
### 前端
- `web/app/servers.html` — 系统信息面板拆分为「发行版」(`siOSRelease` / `os_release`) 和「内核平台」(`siPlatform` / `platform`) 两个独立卡片;描述/目标路径/认证方式空值时隐藏行(`style.display`),目标路径用 `<code>` 标签
## 技术细节
- `_os_release()` 读取 `/etc/os-release`,解析 `PRETTY_NAME=` 行,如文件不存在返回空字符串
- health 端点中也有内联的 os_release 检测(与 heartbeat 的 `_os_release()` 逻辑相同)
- 前端拆分为两个独立卡片:发行版 (`os_release`) + 内核平台 (`platform`),各自独立赋值
## 部署过程问题
- 机器人 (66.154.115.131) Agent 升级后因旧进程占用 8601 端口导致 crash loop
- 原因:旧 Agent 进程未被 kill,新进程无法绑定端口
- 修复:`kill -9 $(lsof -t -i :8601)``systemctl restart nexus-agent`
- 非代码 bug,是升级流程中端口释放竞争问题
## 需要迁移/重启
- 是:需升级 Agentcurl 新 agent.py + systemctl restart nexus-agent
- 否:无数据库迁移,Nexus 后端无需重启(心跳 JSON 透传)
## 验证方式
1. API `/api/servers/` 返回 `system_info.os_release = "Ubuntu 24.04 LTS"`
2. 浏览器 servers.html 点击机器人 → 系统信息面板显示 "Ubuntu 24.04 LTS" ✅
3. 冲量银海显示 "Debian GNU/Linux 12 (bookworm)" ✅
4. 机器人 Agent 心跳正常发送 ✅
5. 空字段(描述/目标路径)整行隐藏 ✅
6. 目标路径编辑保存成功后正确显示 ✅
7. 后端 /health 返回 `{"status":"ok"}`
## 审计8步
- H1: `_os_release()` 只读本地文件 — ✅ 无注入风险
- H2: `os_release` / `platform` 字符串用 `textContent` 赋值 — ✅ 无 XSS
- H3: /etc/os-release 为系统只读文件 — ✅ 无安全风险
- H4: 空字段隐藏用 `style.display`,值固定为空或 none — ✅ 无注入
- H5: Agent 端口占用非代码问题 — ✅ 运维处理
- H6: 审计文件 `docs/audit/2026-05-27-servers-info-display.md` — ✅ 0 FINDING
- DoD: 无明文密码/命令注入/SQL注入/静默吞错/硬编码 ✅
## 进度条
☑实现 ☑WSL验证 ☑审计8步 ☑部署 ☑健康检查 ☑浏览器验证 ☑changelog
@@ -0,0 +1,88 @@
# 2026-05-27 批量按钮修复 + 审计日志中文化 + terminal断开反馈 + 远程安装按钮修复 + Agent安装sudo兼容
## 变更摘要
1. 修复 servers.html 批量"安装 Agent"按钮:hasAgent 判断从 `agent_api_key_set` 改为 `agent_version`,解决未安装 Agent 的服务器按钮不可用的问题
2. 所有后端 AuditLog detail 字段统一为中文(此前混杂英文如 `name=xxx``WebSSH to xxx` 等)
3. audit.html 操作列和目标类型列显示中文映射(`create_server``添加服务器`
4. audit.html 新增 ACTION_NAMES 覆盖所有已知 action 类型(含 webssh_token、refresh_token_mismatch 等)
5. mcp/Nexus_server.py deploy 流程修复:git pull 先于 gate check + origin/main 替代 origin/master
6. **修复详情面板"远程安装"按钮始终禁用的 bug**`s._base_url` 不存在于服务器对象,改为从新 API `GET /api/servers/meta/api_base_url` 获取全局 `NEXUS_API_BASE_URL`
7. `api_base_url` 加入 DB_OVERRIDE_MAP,可在设置页面管理
8. 安装向导新增 `api_base_url` 写入 MySQL settings 表
9. 启动时自动迁移:若 MySQL 缺 `api_base_url``.env` 有值,自动插入
10. **Agent install.sh 非 root 用户兼容**:自动检测当前用户,非 root 时使用 `sudo` 执行 `mkdir`/`apt-get`/`systemctl`/`tee` 等特权操作,解决 Ubuntu 等非 root SSH 用户无法写入 `/opt` 的问题
11. **后端 _sudo_wrap 辅助函数**`servers.py` 新增 `_sudo_wrap(cmd, ssh_user)` — 非 root 用户自动配置 NOPASSWD sudo(写入 `/etc/sudoers.d/nexus-agent`),命令执行后自动清理;应用于 install-agent / batch-install / upgrade-agent / batch-upgrade 共 4 个端点
12. **install.sh venv 创建 set -e 兼容**`python3 -m venv` 在缺 `python3-venv` 的 Ubuntu 系统上返回 exit 1`set -e` 导致脚本直接退出,apt-get 安装 python3-venv 的回退逻辑从未执行。修复:首次 venv 创建加 `|| true` 允许失败,然后检查 activate 是否存在再走回退路径
## 动机
1. 批量操作中,`agent_api_key_set=True` 不等于 Agent 已安装(只代表生成了 Key),导致武汉市风莞溪服务器(有 Key 但无 Agent)的"安装"按钮被错误禁用
2. 审计日志详情混合中英文,运维人员阅读不便
3. MCP deploy 工具先执行 gate check 再 git pull,导致 changelog/audit 文件不在磁盘上被 BLOCK
4. **详情面板"远程安装"按钮始终禁用**`s._base_url` 是前端代码中的错误引用(`API_BASE_URL` 是全局设置,不是服务器的字段),导致所有服务器都无法远程安装 Agent
5. **非 root 用户远程安装 Agent 失败**Ubuntu 等系统默认用户(如 `ubuntu`)无 `/opt` 写权限,install.sh 直接 `mkdir -p /opt/nexus-agent``Permission denied`
6. **install.sh venv 创建被 set -e 终止**Ubuntu 24.04 默认未安装 `python3-venv``python3 -m venv` 返回 exit 1`set -e` 导致脚本直接退出,apt-get 安装 python3-venv 的回退逻辑从未执行
## 涉及文件
### 前端
- `web/app/servers.html` — updateBatchBar() hasAgent 从 `s.agent_api_key_set` 改为 `s.agent_version`loadAgentInstall() base_url_conf 从 `s._base_url` 改为 `_apiBaseUrl`(从新 API 加载);新增 `loadApiBaseUrl()` 函数
- `web/app/audit.html` — 新增 ACTION_NAMES / TARGET_NAMES 中文映射(覆盖所有已知 action/target_type
- `web/app/settings.html` — 基础设施区块新增 `api_base_url`(主站对外 URL
### Agent 安装脚本
- `web/agent/install.sh` — 新增非 root 用户 sudo 兼容:`id -u` 检测 → `$SUDO` 变量 → `mkdir`/`apt-get`/`chown`/`tee`/`systemctl` 全部走 sudovenv 创建加 `|| true` 兼容 set -e
### 后端
- `server/api/servers.py` — 新增 `GET /api/servers/meta/api_base_url` 端点(返回 `settings.API_BASE_URL`
- `server/config.py` — DB_OVERRIDE_MAP 新增 `api_base_url``API_BASE_URL`;新增 `ensure_api_base_url_in_db()` 启动迁移方法
- `server/main.py` — 启动时调用 `settings.ensure_api_base_url_in_db(session)` 迁移现有安装的 api_base_url
- `server/api/install.py` — 安装向导 settings_kv 新增 `api_base_url`
- `server/api/auth.py` — 修改密码详情
- `server/api/servers.py` — 服务器创建/更新/删除/Agent操作详情
- `server/api/scripts.py` — 凭据/脚本操作详情
- `server/api/assets.py` — 平台/节点操作详情
- `server/api/settings.py` — 设置/调度/预设/重试操作详情
- `server/api/webssh.py` — WebSSH 连接/断开详情
- `server/api/sync_v2.py` — 文件操作详情
- `server/api/agent.py` — 长任务回调详情
- `server/background/script_execution_flush.py` — 执行刷新详情
- `server/application/services/auth_service.py` — 登录/TOTP/Token/WebSSH令牌详情
- `server/application/services/script_service.py` — 脚本/凭据/执行详情
- `server/application/services/sync_engine_v2.py` — 文件推送详情
### MCP
- `mcp/Nexus_server.py` — deploy: git pull 先于 gate check + origin/main
## 技术细节
- `agent_version` 由 Agent 心跳时写入(如 `"2.0.0"`),未安装 Agent 时为 `None`
- `agent_api_key_set` 仍保留在安装命令上下文中使用(检测是否生成了 Key,是安装的前提条件)
- `API_BASE_URL` 是全局设置(来自 `.env``NEXUS_API_BASE_URL`),不是服务器字段
- 前端通过 `GET /api/servers/meta/api_base_url` 获取值,缓存到 `_apiBaseUrl` 变量
- `api_base_url` 加入 `DB_OVERRIDE_MAP` 后,设置页面可查看/修改,MySQL 中的值会覆盖 `.env`
- 启动迁移逻辑:若 MySQL settings 表无 `api_base_url` 行但有 `.env` 值,自动插入(仅一次)
- 前端 ACTION_NAMES 映射覆盖所有已知 action 类型,未知 action 仍显示原始英文值
- 历史数据(已存入 MySQL 的 detail)不会追溯更改,仅新操作生效
- MCP deploy 流程:git pull → gate check → supervisorctl restart
- install.sh sudo 兼容:`id -u == 0``SUDO=""`,否则检查 `sudo` 可用性;`$SUDO mkdir -p /opt/nexus-agent` + `$SUDO chown` 给当前用户 → 后续 venv/pip 无需 sudo`$SUDO tee /etc/systemd/system/nexus-agent.service` + `$SUDO systemctl` 处理 systemd 操作
- install.sh venv `set -e` 兼容:首次 `python3 -m venv``2>/dev/null || true`,允许在缺 `python3-venv` 时失败不退出;然后检查 `.venv/bin/activate` 是否存在,不存在则 `$SUDO apt-get install python3-venv` 后重试
## 需要迁移/重启
- 是:后端 Python 需重启(新端点 + 启动迁移逻辑 + DB_OVERRIDE_MAP 变更)
- 否:无数据库 schema 迁移(仅向 settings 表插入一行)
## 验证方式
1. 选择未安装 Agent 的服务器 → "安装 Agent" 按钮可点击 ✅
2. 选择已安装 Agent 的服务器 → "升级 Agent" 按钮可点击 ✅
3. 审计日志页面操作列显示中文 ✅
4. 新操作审计日志详情显示中文 ✅
5. MCP deploy 工具流程正确(git pull 先于 gate check)✅
6. 详情面板:有 agent_api_key 的服务器 → "远程安装"按钮可用 ✅
7. 详情面板:无 API Key 的服务器 → 显示"请先生成 Key"警告 ✅
8. 设置页面 → 基础设施区块显示"主站对外 URL" ✅
9. 启动日志 → 显示 "Migrated api_base_url to MySQL settings table" ✅
10. 非 root 用户远程安装 Agent → install.sh 自动使用 sudo`/opt/nexus-agent` 创建成功 ✅
11. root 用户远程安装 Agent → install.sh 无 sudo 前缀,行为不变 ✅
12. 缺 python3-venv 的 Ubuntu → install.sh 自动 apt-get install python3-venv 后重试 ✅
## 进度条
☑实现 ☑WSL验证 ☑审计8步 ☑部署 ☑健康检查 ☑浏览器验证 ☑changelog
@@ -0,0 +1,47 @@
# 2026-05-27 fmtTime Invalid Date修复 + install.sh venv健壮性 + batch install错误上报
## 变更摘要
1. 修复6个页面的 fmtTime() 函数对带时区后缀(+00:00)的时间戳解析为 Invalid Date
2. install.sh 创建 venv 后检测 activate 文件,缺失时自动安装 python3-venv 重试
3. batch install API 异常时保留 server_name、检测 stdout 中 "Status: FAILED"、stderr 为空时返回提示信息
## 动机
1. API 返回的 last_heartbeat 格式为 `2026-05-26T16:22:14.444657+00:00`fmtTime() 无脑追加 `Z` 导致双重时区标记 `+00:00Z`JS Date 解析失败返回 Invalid Date
2. Debian 12 默认不带 python3-venv 包,`python3 -m venv` 创建目录但不生成 activate 脚本,source 报错且无明确提示
3. batch install API 返回 `success:false, stdout:"", error:""` 无具体错误信息,无法排查失败原因
## 涉及文件
### 前端(6个页面)
- `web/app/servers.html` — fmtTime: 先 new Date(t),失败再 new Date(t+'Z')
- `web/app/index.html` — 同上
- `web/app/credentials.html` — 同上
- `web/app/push.html` — 同上
- `web/app/retries.html` — 同上
- `web/app/schedules.html` — 同上
### Agent 安装脚本
- `web/agent/install.sh` — venv 创建后检测 activate 文件是否存在,不存在则 apt install python3-venv 后重试
### 后端
- `server/api/servers.py` — batch install _install_one(): server_name 提前赋值、检测 "Status: FAILED"、stderr 空时返回提示、超时改为180s
## 需要迁移/重启
- 是:需重启 Nexus 后端(`supervisorctl restart nexus`
- 否:无数据库迁移
## 验证方式
1. 浏览器打开 servers.html → 最后心跳列显示 "5月27日 00:52" 而非 "Invalid Date" ✅
2. 在缺少 python3-venv 的 Debian 12 服务器运行 install.sh → 自动安装 python3-venv 后继续 ✅
3. batch install 失败时返回具体错误信息 ✅
## 审计8步
- H1: install.sh apt-get 需 root — ✅ 正常(脚本以 root 运行)
- H2: install.sh 检测 activate 而非 -d 目录 — ✅ 修复旧 bug
- H3: servers.py shlex.quote 防注入 — ✅ 已有
- H4: servers.py "Status: FAILED" 字符串匹配 — ✅ 无注入风险
- H5: fmtTime 纯前端日期解析 — ✅ 无 XSS(用 esc() 函数)
- DoD: 无明文密码/命令注入/SQL注入/静默吞错/硬编码 ✅
## 进度条
☑实现 ☑WSL验证 ☑审计8步 ☑部署 ☑健康检查 ☑浏览器验证 ☐changelog(本文件)
@@ -0,0 +1,61 @@
# 2026-05-28 推送页面 5 项迭代功能
## 变更摘要
1. F1: 推送进度实时追踪 — WebSocket 逐台推送进度更新
2. F2: 服务器分组选择 — 分类/平台筛选 + 选中当前筛选
3. F4: 推送历史增强 — 可展开详情面板(耗时/错误/diff)
4. F7: 文件管理器操作 — 解压文件删除/重命名
5. F8: 推送后校验 — md5sum 对比本地源与远程目标
## 动机
推送页面已完成 ZIP 上传 + rsync 推送基础功能,但存在 5 个体验缺陷:批量推送无实时反馈、2000+ 服务器无法分组选择、历史记录无详情、文件管理器只读、推送后无法校验文件一致性。
## 涉及文件
### 后端
- `server/api/websocket.py` — 新增 `broadcast_sync_progress()` 函数
- 参数: batch_id, server_id, server_name, status, completed, failed, total, error_message, duration_seconds
- 通过 `_dispatch_ws_message()` 发布到 Redis Pub/Sub + 本地 WebSocket
- `server/application/services/sync_engine_v2.py` — 推送引擎改造
- `sync_files()`: 新增 `batch_id` (uuid) 用于 WS 消息过滤
- `_sync_one()`: 计数器更新后调用 `broadcast_sync_progress()`
- `_sync_one()`: 捕获 `diff_summary = result["stdout"][:2000]`
- `_log_to_dict()`: 新增 files_transferred, files_skipped, bytes_transferred, diff_summary
- 返回 dict 新增 `batch_id`
- `server/api/servers.py` — 服务器列表 + 历史增强
- `list_servers()`: 新增 `platform_id`, `node_id` Query 参数
- 新增 `GET /api/servers/categories` 端点(分类+平台列表)
- `_sync_log_to_dict()`: 新增 files_skipped, bytes_transferred, diff_summary
- `server/infrastructure/database/server_repo.py``get_paginated()` 增加 `platform_id`, `node_id` 参数
- `server/api/schemas.py` — 新增 `LocalFileOperation`, `SyncVerify` 模型
- `server/api/sync_v2.py` — 新增端点
- `POST /api/sync/local-file-ops`: 删除/重命名上传临时目录中的文件
- `POST /api/sync/verify`: md5sum 对比本地源与远程目标
### 前端
- `web/app/push.html` — 全面改造
- F1: WebSocket 连接 `connectPushWS()` + `updateProgressFromWS()` 逐台更新
- F2: 分类/平台筛选下拉框 + `filterServers()` + "选中当前筛选" 按钮
- F4: 历史记录可点击展开详情(源/目标路径、耗时、错误、diff_summary
- F7: 文件管理器 hover 显示操作按钮 + `deleteLocalFile()`/`renameLocalFile()`
- F8: "推送后校验" 复选框 + `doVerify()` + 校验结果面板
## 安全影响
- F1 (WS): WS 消息需 JWT 认证 + batch_id 过滤防混淆
- F7 (文件操作): `os.path.realpath()` + `startswith("/tmp/nexus_upload_")` 双重校验,新路径也必须在上传目录内
- F8 (校验): SSH 命令中文件名通过 `shlex.quote()` 防注入
- 所有新端点需 JWT 认证 + 审计日志
## 需要迁移/重启
- 是:后端 Python 需重启(supervisorctl restart nexus
- 否:无数据库 schema 迁移
## 验证方式
1. F1: 推送 3+ 台服务器 → 观察 WS 逐台更新状态(不等全部完成)
2. F2: 分类筛选 → 选中筛选 → 服务器列表正确过滤
3. F4: 点击历史记录 → 展开详情 → 显示耗时/错误/diff
4. F7: 上传 ZIP → 文件管理器 → 删除/重命名文件 → 刷新确认
5. F8: 勾选校验 → 推送 → 校验结果正确显示匹配/缺失
## 进度条
☑实现 ☑WSL验证 ☐审计8步 ☐部署 ☐健康检查 ☐浏览器验证 ☐changelog
@@ -0,0 +1,57 @@
# 2026-05-29 推送页面 Round 2 迭代功能
## 变更摘要
1. F5: 同步模式详细说明 — 每种模式下方显示说明文字,切换时动态更新
2. F2: 服务器在线状态标识 — 服务器列表选项显示🟢/🔴在线/离线标识
3. F1: 失败自动重试 — 推送失败自动创建重试任务 + 失败行"🔄 重试"按钮
4. F3: 推送完成 Telegram 通知 — 推送完成/部分失败/全部失败发 Telegram 消息含详情
5. F4: 文件内容预览 — 文件管理器点击文件名弹出内容预览模态框
## 动机
推送页面 Round 1 功能已上线,但存在 5 个体验/运维缺陷:同步模式含义不明、无法区分在线/离线服务器、推送失败需手动跳转重试页面、推送完成无远程通知、文件管理器无法预览文件内容确认推送正确性。
## 涉及文件
### 后端
- `server/application/services/sync_engine_v2.py` — 失败自动创建 PushRetryJob + Telegram 通知
- `_sync_one()`: 失败时自动创建 `PushRetryJob(status="pending", max_retries=3)`
- `sync_files()`: 完成后调用 `send_telegram_sync_complete()` 发 Telegram
- 返回 dict 中失败 server 增加 `retry_job_id` 字段
- `server/api/websocket.py``broadcast_sync_progress()` 增加 `retry_job_id` 参数
- `server/infrastructure/telegram/__init__.py` — 新增 `send_telegram_sync_complete()` 函数
- 参数: completed, failed, total, source_path, operator, failed_servers, duration_seconds
- 全部成功🟢 / 部分失败🟡 / 全部失败🔴 三种消息样式
- 失败服务器列表最多显示 10 台
- `server/api/sync_v2.py` — 新增 `POST /api/sync/local-file-preview` 端点
- 安全: `os.path.realpath()` + `startswith("/tmp/nexus_upload_")` 校验
- 读取最多 4KBbase64 编码返回
- UTF-8 检测: 成功则 `encoding_hint="text"`, 否则 `"binary"`
- 超过 1MB 的文件不支持预览
- 审计日志记录
- `server/api/schemas.py` — 新增 `LocalFilePreview` 模型
### 前端
- `web/app/push.html` — 5 项改动
- F5: 同步模式 radio 组下方 `<div id="syncModeDesc">` 说明区域,`onSyncModeChange()` 更新
- F2: `filterServers()` 选项文字加🟢/🔴前缀 + 离线灰色样式
- F1: `showProgress()` 每行加 `srv_retry_{id}` 重试按钮;`updateProgressFromWS/Result()` 失败时显示;`retryServer()` 调用重试 API
- F4: 文件名加 `cursor-pointer hover:text-brand-light` 点击预览;`previewLocalFile()` 调用 API + 模态框显示;二进制文件提示
## 安全影响
- F4 (文件预览): `os.path.realpath()` + `startswith("/tmp/nexus_upload_")` 双重校验,与 browse-local/local-file-ops 一致
- F1 (自动重试): 失败后自动创建 pending 重试任务,由后台 retry_runner 执行,复用现有重试安全机制
- F3 (Telegram): `sanitize_external_message()` 脱敏处理源路径,`html.escape()` 防 XSS
## 需要迁移/重启
- 是:后端 Python 需重启(supervisorctl restart nexus
- 否:无数据库 schema 迁移(PushRetryJob 表已存在)
## 验证方式
1. F5: 切换同步模式 → 下方说明文字正确更新
2. F2: 服务器列表 → 在线服务器显示🟢、离线显示🔴
3. F1: 推送失败 → 失败行出现"🔄 重试"按钮 → 点击重试 → 重新推送
4. F3: 推送完成 → Telegram 收到通知(含成功/失败数、失败服务器名)
5. F4: 上传 ZIP → 文件管理器 → 点击文件名 → 弹出预览框显示内容
## 进度条
☑实现 ☐WSL验证 ☐审计8步 ☐部署 ☐健康检查 ☐浏览器验证 ☐changelog
+60
View File
@@ -0,0 +1,60 @@
# 2026-05-29 推送页面迭代 Round 2 — 5 项功能
## 变更摘要
1. F1: 失败自动重试 — 推送失败自动创建重试任务,行内"🔄 重试"按钮
2. F2: 服务器在线状态标识 — 服务器列表显示🟢/🔴在线状态
3. F3: 推送完成 Telegram 通知 — 推送完成/部分失败发 Telegram 消息含详情
4. F4: 文件内容预览 — 文件管理器点击文件名预览内容
5. F5: 同步模式详细说明 — 每种模式下方显示说明文字
## 动机
推送页面 Round 1 完成后,实际使用中发现:推送失败需手动切页重试、无法识别服务器在线状态、推送完成无即时通知、文件管理器无法预览内容、同步模式含义不明确。
## 涉及文件
### 后端
- `server/application/services/sync_engine_v2.py` — 推送失败自动创建 PushRetryJob + Telegram 通知
- `_sync_one()`: 失败时创建 PushRetryJob,记录 retry_job_id
- `sync_files()`: 完成后调用 `send_telegram_sync_complete()`
- 返回 dict 新增 `retry_job_ids`
- `server/api/websocket.py``broadcast_sync_progress()` 新增 `retry_job_id` 参数
- `server/infrastructure/telegram/__init__.py` — 新增 `send_telegram_sync_complete()` 函数
- 参数: completed, failed, total, source_path, operator, failed_servers, duration_seconds
- 全部成功🟢/部分失败🟡/全部失败🔴 不同消息格式
- `server/api/sync_v2.py` — 新增 `POST /api/sync/local-file-preview` 端点
- 安全: `os.path.realpath()` + `startswith("/tmp/nexus_upload_")` 校验
- 限制: 仅文件 + 最多 4KB + base64 编码 + encoding_hint
- 审计日志
- `server/api/schemas.py` — 新增 `LocalFilePreview` 模型
### 前端
- `web/app/push.html` — 全面改造
- F1: WS 消息处理 `updateProgressFromWS()` 失败行显示"🔄 重试"按钮 + `retryServer()` 函数
- F1: HTTP 响应 `updateProgressFromResult()` 同样显示重试按钮 + `_retryJobMap` 映射
- F2: `filterServers()` 服务器选项加🟢/🔴在线状态标识
- F4: `renderFileManager()` 文件名可点击 + `previewLocalFile()` 预览函数 + 模态框
- F5: 同步模式下方 `<div id="syncModeDesc">` 说明文字 + `onSyncModeChange()` 更新
### 设计/技术文档
- `docs/design/specs/2026-05-29-push-round2-design.md` — 设计文档
- `docs/design/plans/2026-05-29-push-round2.md` — 技术文档
## 安全影响
- F1 (重试): 利用已有 `/api/retries/{id}/retry` 端点,JWT 认证 + 审计日志
- F3 (Telegram): `source_path``sanitize_external_message()` 脱敏
- F4 (预览): `os.path.realpath()` + `startswith("/tmp/nexus_upload_")` 双重校验 + 4KB 限制
- 所有新端点需 JWT 认证 + 审计日志
## 需要迁移/重启
- 是:后端 Python 需重启(supervisorctl restart nexus
- 否:无数据库 schema 迁移
## 验证方式
1. F5: 切换同步模式 → 下方说明文字正确更新
2. F2: 服务器列表 → 在线🟢、离线🔴标识
3. F1: 推送失败 → 行内"🔄 重试"按钮 → 点击重试
4. F3: 推送完成 → Telegram 收到通知含详情
5. F4: 上传 ZIP → 点击文件名 → 弹出预览框
## 进度条
☑实现 ☐WSL验证 ☐审计8步 ☐部署 ☐健康检查 ☐浏览器验证 ☐changelog
+71
View File
@@ -0,0 +1,71 @@
# 2026-05-29 推送页面 Round 3 迭代
## 变更摘要
1. G1: 推送取消 — Redis 取消标记 + 前端"取消推送"按钮 + WS cancelled 状态
2. G2: 推送页面直接调度 — "⏰ 定时推送"按钮 + 时间选择模态框 + 调用调度 API
3. G3: 推送模板/收藏 — localStorage 模板存储 + 下拉选择 + 保存/删除
4. G4: 推送历史筛选分页 — 后端 page/per_page 参数 + 前端状态/服务器筛选 + 分页控件
5. G5: 浏览器推送完成通知 — Web Notification API + title 闪烁降级
6. G6: 拖拽上传 ZIP — 拖拽区 dragover/drop + 高亮 + click 回退
## 动机
推送页面 Round 1+2 已完成 10 项功能,但仍有 6 项体验/效率缺陷:推送无法取消导致只能等超时、定时推送需跳到调度页重新配置、常用推送组合每次手动选、历史无法筛选分页、离开页面不知推送结果、文件选择器不够直观。
## 涉及文件
### 后端
- `server/api/schemas.py` — 新增 `SyncCancel` 模型 (batch_id: str, max_length=32)
- `server/api/sync_v2.py` — 新增 `POST /api/sync/cancel` 端点
- Redis SET `sync:cancel:{batch_id}` EX 3600
- JWT 认证 + 审计日志
- 返回 `{cancelled: true, batch_id: ...}`
- `server/api/websocket.py``broadcast_sync_progress()` 增加 `cancelled: int = 0` 参数
- WS 消息新增 `cancelled` 字段,前端用于正确计算进度条
- `server/application/services/sync_engine_v2.py` — 推送取消检查
- `_sync_one()`: 获取信号量后、rsync 前检查 `sync:cancel:{batch_id}`
- 取消的服务器 status="cancelled",创建 SyncLog 记录
- 新增 `cancelled` 计数器,与 `completed`/`failed` 并列
- 正常路径 WS 广播也传递 `cancelled` 计数
- Redis 异常不再静默吞错,改用 `logger.warning()`
- `server/api/servers.py``get_all_sync_logs()` 参数改为 page/per_page + 返回 pages
- 旧: `offset: int, limit: int` → 新: `page: int = 1, per_page: int = 20`
- 返回增加: `page`, `per_page`, `pages` (ceil(total/per_page))
### 前端
- `web/app/push.html` — 6 项改动
- G1: 推送进度区"取消推送"按钮 + `cancelPush()` + WS cancelled 状态渲染 ⊘
- G2: "⏰ 定时推送"按钮 + 时间选择模态框 + `doSchedulePush()`
- G3: 模板下拉框 + `saveTemplate()`/`applyTemplate()`/`deleteTemplate()` (localStorage)
- G4: 历史区状态筛选下拉 + 服务器ID输入 + 分页控件
- G5: 推送完成时 `new Notification()` + 授权拒绝时 title 闪烁降级
- G6: 拖拽上传区 dragover/drop/dragleave + 高亮边框 + click 回退
- 安全: `esc()` 函数增加单引号转义 `&#39;`,防止 onclick 属性 JS 注入
## 安全影响
- G1 (取消): Redis key 有 1h TTL 防泄漏;batch_id 为服务器生成的 UUID hex;取消端点有 JWT 认证 + 审计
- G2 (调度): 复用现有调度 API,已有 JWT 认证 + 审计
- G3 (模板): localStorage 仅存路径/服务器ID,不含密码/密钥
- G4 (分页): SQLAlchemy 参数化查询,无 SQL 注入风险
- G5 (通知): Notification API 需用户主动授权,非强制
- G6 (拖拽): 文件仍走 upload-zip 端点校验
## 审计修复
- F1: 取消服务器计数器 → 新增 `cancelled` 计数器,WS 广播含 `cancelled` 字段,前端进度条正确计算
- F2: 静默异常 → `except Exception: pass` 改为 `logger.warning()`
- F3: 异常链抑制 → 移除 `from None`
- F4: XSS 防御 → `esc()` 增加单引号转义
## 需要迁移/重启
- 是:后端 Python 需重启(supervisorctl restart nexus
- 否:无数据库 schema 迁移
## 验证方式
1. G1: 推送 10 台 → 点击"取消推送" → 未启动的显示"已取消" ⊘ → 进度条正确
2. G2: 配置推送 → 点击"⏰ 定时推送" → 选择时间 → 调度页出现任务
3. G3: 保存模板 → 刷新 → 选择模板 → 表单自动填充
4. G4: 历史区筛选"失败" → 仅显示失败 → 分页翻页
5. G5: 推送完成 → 浏览器弹出通知
6. G6: 拖拽 ZIP 到上传区 → 高亮 → 释放上传成功
## 进度条
☑实现 ☑WSL验证 ☑审计8步 ☐部署 ☐健康检查 ☐浏览器验证 ☑changelog
+50
View File
@@ -0,0 +1,50 @@
# 2026-05-29 推送页面 Round 4 迭代
## 变更摘要
1. H2: 服务器搜索过滤 — 目标服务器下拉列表上方加搜索框,按名称/域名实时过滤
2. H3: 推送对比 Diff 视图 — 预览文件列表中加"📄 diff"按钮,点击后对比本地与远程文件内容差异
3. H4: 推送排错面板 — 失败行加"🔍 诊断"按钮,检查 SSH 连通/磁盘空间/目标路径权限/写入权限
4. H5: 源路径直接输入 — 除 ZIP 上传外,支持直接输入服务器本地路径推送
5. H6: 批量重试 — 推送完成后,一键重试所有失败服务器
## 动机
推送页面功能已较完善,但 5 个实际使用痛点:2000+ 服务器下拉无法搜索定位、推送前无法看到文件内容差异、推送失败后需手动 SSH 排查、已有文件需先下载再上传、批量失败需逐台点重试。
## 涉及文件
### 后端
- `server/api/schemas.py` — 新增 `ValidateSourcePath`, `SyncDiagnose`, `FileSyncDiff` 三个 Schema
- `server/api/sync_v2.py` — 新增 3 个端点
- `POST /api/sync/validate-source-path`: 验证本地目录路径有效性(禁止 /etc /root /home 等敏感路径)
- `POST /api/sync/diagnose`: 诊断推送失败原因(SSH 连通 → 磁盘空间 → 路径权限 → 写入测试)
- `POST /api/sync/file-diff`: 对比本地文件与远程文件差异(difflib.unified_diff,限 100KB
- `docs/design/specs/2026-05-29-push-round4-design.md` — 设计文档
- `docs/design/plans/2026-05-29-push-round4.md` — 技术文档
### 前端
- `web/app/push.html` — 5 项改动
- H2: `<input id="serverSearch">` + `filterServerOptions()` 按 oninput 实时过滤
- H3: 预览文件列表渲染 HTML 带 diff 按钮 + `showFileDiff()` + diff 模态框(新增绿/删除红/头部青)
- H4: 服务器行加 `srv_diag_{id}` 按钮 + `diagnoseServer()` + 诊断模态框
- H5: "或输入源路径" 输入框 + "验证路径" 按钮 + `validateSourcePath()`
- H6: 进度条区域"🔄 重试全部失败"按钮 + `retryAllFailed()` 批量提交重试
## 安全影响
- H5 (源路径验证): `os.path.realpath()` 校验 + 禁止 `/etc` `/root` `/home` `/var` 等敏感路径前缀
- H3 (文件对比): realpath 校验 local_file 必须在 source_path 下,source_path 必须 `startswith("/tmp/nexus_upload_")`
- H4 (诊断): `shlex.quote()` 防注入,SSH 操作复用连接池
- 所有新端点需 JWT 认证 + 审计日志
## 需要迁移/重启
- 是:后端 Python 需重启(supervisorctl restart nexus
- 否:无数据库 schema 迁移
## 验证方式
1. H2: 输入 "web" → 下拉只显示名称/域名含 "web" 的服务器
2. H3: 预览 → 文件列表 → 点 "📄 diff" → 显示逐行对比(新增绿/删除红)
3. H4: 推送失败 → 点"🔍 诊断" → SSH/磁盘/权限检查结果
4. H5: 输入 `/tmp/nexus_upload_xxx` → 验证成功 → 可推送
5. H6: 推送完成后有失败 → 显示"重试全部失败"按钮 → 点击批量重试
## 进度条
☑实现 ☑WSL验证 ☐审计8步 ☑部署 ☐健康检查 ☐浏览器验证 ☑changelog
+134
View File
@@ -0,0 +1,134 @@
# 2026-05-29 推送页面迭代 Round 2 — 技术文档
## 涉及文件清单
| 文件 | 改动类型 | 涉及功能 |
|------|---------|---------|
| `server/infrastructure/telegram/__init__.py` | 新增函数 | F3 Telegram |
| `server/application/services/sync_engine_v2.py` | 修改 | F1 重试 + F3 Telegram |
| `server/api/websocket.py` | 修改参数 | F1 重试 |
| `server/api/sync_v2.py` | 新增端点 | F4 预览 |
| `server/api/schemas.py` | 新增模型 | F4 预览 |
| `web/app/push.html` | 修改 | F1+F2+F4+F5 前端 |
| `docs/changelog/2026-05-29-push-round2.md` | 新增 | changelog |
## 实现步骤
### Step 1: F5 同步模式详细说明(纯前端)
**文件**: `web/app/push.html`
1. 同步模式 `</div>` 后增加 `<div id="syncModeDesc">` 说明文字
2. `onSyncModeChange()` 函数末尾增加说明文字更新逻辑
3. 默认显示增量同步说明
说明内容:
- 增量同步:仅传输源目录中新增或修改的文件,目标已有且未变的文件跳过。安全快速,适合日常更新。
- 全量同步:使目标目录与源目录完全一致,会删除目标中不存在于源的文件。⚠️ 可能造成数据丢失,建议先预览。
- 校验和:通过文件内容校验和(而非修改时间+大小)判断是否需要传输。更精确但更慢,适合时钟不同步的环境。
### Step 2: F2 服务器在线状态标识(纯前端)
**文件**: `web/app/push.html`
1. `filterServers()` 中渲染 `<option>` 时,根据 `s.is_online` 加🟢/🔴前缀
2. 离线服务器选项加 `style="color: #666"` 提示
3. 数据来源:`/api/servers/` 已返回 `is_online`(从 Redis heartbeat 实时读取),无需改后端
### Step 3: F1 失败自动重试(后端+前端)
**后端**:
1. `server/application/services/sync_engine_v2.py``_sync_one()`:
-`sync_log.status == "failed"` 时,创建 `PushRetryJob`:
```python
retry_job = PushRetryJob(
server_id=server.id, server_name=server.name,
source_path=source_path, target_path=dest,
status="pending", retry_count=0, max_retries=3,
next_retry_at=datetime.now(timezone.utc),
last_error=sync_log.error_message[:500] if sync_log.error_message else None,
)
retry_job = await self.retry_repo.create(retry_job)
```
- 将 `retry_job.id` 存入变量,传给 WS 广播
2. `server/api/websocket.py` — `broadcast_sync_progress()` 增加参数:
- `retry_job_id: int = None`
- WS 消息中增加 `retry_job_id` 字段
**前端**:
1. `updateProgressFromWS()` — 失败行渲染"🔄 重试"按钮
2. `updateProgressFromResult()` — 同上
3. 新增 `retryServer(retryJobId)` — 调用 `POST /api/retries/{id}/retry`
4. 存储映射 `_retryJobMap = {}` — `server_id → retry_job_id`
### Step 4: F3 推送完成 Telegram 通知(后端)
1. `server/infrastructure/telegram/__init__.py` — 新增函数:
```python
async def send_telegram_sync_complete(
completed: int, failed: int, total: int,
source_path: str, operator: str,
failed_servers: list[str] = None,
duration_seconds: int = None,
) -> bool:
```
- 消息格式:HTML,含 emoji 标识状态
- 全部成功: 🟢 推送完成
- 部分失败: 🟡 推送完成(部分失败)+ 失败服务器名列表
- 全部失败: 🔴 推送失败
- `source_path` 经 `sanitize_external_message()` 脱敏
2. `server/application/services/sync_engine_v2.py` — `sync_files()`:
- `asyncio.gather()` 完成后,计算总耗时
- 收集失败服务器名列表
- 调用 `send_telegram_sync_complete()`
- try/except 包裹,Telegram 失败不影响推送结果
### Step 5: F4 文件内容预览(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增:
```python
class LocalFilePreview(BaseModel):
path: str = Field(..., min_length=1)
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/local-file-preview`:
- 安全:`os.path.realpath()` + `startswith("/tmp/nexus_upload_")`
- 限制:仅文件(非目录)+ 最多读 4KB
- 返回:`{name, size, content_base64, truncated, encoding_hint}`
- `encoding_hint`: 尝试 UTF-8 解码,成功 `"text"` 否则 `"binary"`
- 审计日志
**前端**:
1. `renderFileManager()` — 文件名加 `cursor-pointer` + `onclick="previewLocalFile(path, name)"`
2. 新增 `previewLocalFile(path, name)`:
- 调用 `/api/sync/local-file-preview`
- 弹出模态框显示文件名、大小、内容
- 二进制文件提示"无法预览"
3. 新增模态框 HTML`#previewModal`
## 依赖与配置变更
无新依赖。无数据库 schema 迁移。
## 测试要点
1. F5: 切换同步模式 → 说明文字正确切换
2. F2: 在线服务器🟢、离线服务器🔴显示正确
3. F1: 推送失败 → 行内重试按钮 → 点击重试成功
4. F3: 推送完成 → Telegram 收到通知
5. F4: 点击文件 → 预览模态框显示内容
6. F4: 点击二进制文件 → 提示无法预览
7. F4 安全: 尝试预览 `/etc/passwd` → 403 拒绝
## 回滚方式
所有改动均为增量(新增函数/端点/前端元素),回滚只需:
1. `git revert` 提交
2. `supervisorctl restart nexus`
+135
View File
@@ -0,0 +1,135 @@
# 2026-05-29 推送页面迭代 Round 3 — 技术文档
## 涉及文件清单
| 文件 | 改动类型 | 涉及功能 |
|------|---------|---------|
| `server/application/services/sync_engine_v2.py` | 修改 | G1 取消检查 |
| `server/api/sync_v2.py` | 新增端点 | G1 取消端点 |
| `server/api/schemas.py` | 新增模型 | G1 取消请求 |
| `server/api/servers.py` | 修改参数 | G4 历史筛选分页 |
| `web/app/push.html` | 修改 | G1-G6 全部前端 |
| `docs/changelog/2026-05-29-push-round3.md` | 新增 | changelog |
## 实现步骤
### Step 1: G1 推送取消(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增:
```python
class SyncCancel(BaseModel):
batch_id: str = Field(..., min_length=1, max_length=32)
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/cancel`:
- Redis `SET sync:cancel:{batch_id} 1 EX 3600`
- 审计日志
- 返回 `{"cancelled": true, "batch_id": ...}`
3. `server/application/services/sync_engine_v2.py`:
- `_rsync_push()`: 执行前检查 `sync:cancel:{batch_id}`,存在则返回 `{"exit_code": -1, "stderr": "推送已取消"}`
- `sync_files()`: `_sync_one()` 任务启动前(`async with sem` 之后、rsync 之前)检查取消标记
- 取消的服务器 status=`"cancelled"`,不创建 retry job
**前端**:
1. `doPush()` 执行期间显示"取消推送"按钮
2. `cancelPush()` — 调用 `POST /api/sync/cancel`
3. `updateProgressFromWS()` — 处理 `status=cancelled`
4. 取消后按钮隐藏,已完成的服务器结果保留
### Step 2: G6 拖拽上传 ZIP(纯前端)
**文件**: `web/app/push.html`
1. 将 `#sourceUpload` 区域改为拖拽区:
- `<div>` 加 `dragover/dragleave/drop` 事件
- `dragover`: 高亮边框 `border-brand`
- `drop`: 取 `ev.dataTransfer.files[0]`,调用 `uploadZip()` 逻辑
2. 保留 `<input type="file">` 隐藏,拖拽区 click 触发 `zipFile.click()`
3. 文件类型校验:仅 `.zip`
### Step 3: G5 浏览器推送完成通知(纯前端)
**文件**: `web/app/push.html`
1. `doPush()` 开始时请求 `Notification.requestPermission()`
2. 推送完成(`doPush()` finally 块前)发送通知:
```js
if (Notification.permission === 'granted') {
new Notification('Nexus 推送完成', {body: `${completed} 成功, ${failed} 失败`});
}
```
3. 降级:授权拒绝时 `document.title` 闪烁(3 次交替 `🔔 Nexus` / `Nexus — 推送`
### Step 4: G2 推送页面直接调度(纯前端)
**文件**: `web/app/push.html`
1. "开始推送"按钮旁加"⏰ 定时推送"按钮
2. 点击弹出简单模态框:`datetime-local` 选择器 + 确认按钮
3. 确认后调用 `POST /api/schedules/`body 包含:
- `name`: "手动推送-{时间}"
- `schedule_type`: "push"
- `run_mode`: "once"
- `fire_at`: 选择的时间
- `source_path`, `server_ids`, `sync_mode`: 从当前表单取
4. 成功后 toast 提示 + 链接到调度页面
### Step 5: G3 推送模板/收藏(纯前端)
**文件**: `web/app/push.html`
1. localStorage key: `nexus_push_templates` — JSON 数组
2. 模板结构: `{name, source_path, target_path, sync_mode, server_ids[]}`
3. UI:
- 表单上方"模板"下拉框 + "保存当前配置"按钮
- 选择模板 → 自动填充 source_path/target_path/sync_mode/server_ids
- 下拉旁"删除"按钮删除模板
4. `saveTemplate()`: 弹出 prompt 输入名称 → 存入 localStorage
5. `loadTemplate()`: 从 localStorage 读取模板列表 → 渲染下拉框
6. `applyTemplate()`: 填充表单字段
7. `deleteTemplate()`: 从 localStorage 移除
### Step 6: G4 推送历史筛选(后端+前端)
**后端**:
1. `server/api/servers.py` — `list_sync_logs()`:
- 新增参数: `status: Optional[str]`, `server_id: Optional[int]`, `page: int = 1`
- 增加过滤条件到 SQLAlchemy 查询
- 增加 `COUNT(*)` 查询获取总数
- 返回增加: `total`, `page`, `pages`
**前端**:
1. 历史区增加筛选控件:
- 状态下拉: 全部/成功/失败
- 服务器搜索输入框
2. `loadHistory()` 增加参数传递
3. 分页控件: 上一页/下一页 + 页码显示
## 依赖与配置变更
无新依赖。无数据库 schema 迁移。
G1 需要已有的 Redis 连接。
## 测试要点
1. G1: 推送 10 台 → 取消 → 仅已完成的有结果,其余显示"已取消"
2. G1: 推送完成后再取消 → 无效果(batch_id 已失效)
3. G2: 定时推送 → 调度页出现对应任务 → 到时间自动执行
4. G3: 保存模板 → 刷新 → 选择模板 → 表单填充正确
5. G3: 删除模板 → 下拉框更新
6. G4: 筛选"失败" → 仅显示失败记录 → 分页翻页
7. G5: 推送完成 → 浏览器通知弹出 → 点击通知回到页面
8. G6: 拖拽 ZIP → 上传解压成功 → 文件管理器显示
9. G6: 拖拽非 ZIP → 提示仅支持 .zip
## 回滚方式
所有改动均为增量(新增端点/前端元素),回滚只需:
1. `git revert` 提交
2. `supervisorctl restart nexus`
+138
View File
@@ -0,0 +1,138 @@
# 2026-05-29 推送页面迭代 Round 4 — 技术文档
## 涉及文件清单
| 文件 | 改动类型 | 涉及功能 |
|------|---------|---------|
| `server/api/schemas.py` | 新增模型 | H3 diff + H4 diagnose + H5 validate |
| `server/api/sync_v2.py` | 新增端点 | H3 file-diff + H4 diagnose + H5 validate |
| `web/app/push.html` | 修改 | H2 搜索 + H3 diff UI + H4 诊断 UI + H5 源路径 + H6 批量重试 |
| `docs/changelog/2026-05-29-push-round4.md` | 新增 | changelog |
## 实现步骤
### Step 1: H2 服务器搜索过滤(纯前端)
**文件**: `web/app/push.html`
1. `<select id="targetServers">` 上方加 `<input id="serverSearch" placeholder="搜索服务器...">`
2. 新增 `filterServerOptions()` 函数:读取搜索词,遍历 `<select>``<option>`,按名称/域名匹配隐藏/显示
3. `filterServers()` 加载完成后调用 `filterServerOptions()`
4. 搜索框 `oninput` 事件绑定 `filterServerOptions()`
### Step 2: H5 源路径直接输入(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增 `ValidateSourcePath(BaseModel)`:
```python
class ValidateSourcePath(BaseModel):
path: str = Field(..., min_length=1)
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/validate-source-path`:
- 安全: `os.path.realpath()` 校验,禁止 `/etc`, `/root`, `/home`, `/var` 等敏感前缀
- 验证: 存在 + 是目录 + 统计文件数和大小
- 返回: `{valid, is_dir, file_count, size_bytes}`
- 审计日志
**前端**:
1. 上传区域下方加:
```html
<div class="flex items-center gap-2 mt-2">
<span class="text-xs text-slate-500">或输入源路径:</span>
<input id="sourcePathInput" placeholder="/path/to/files" class="flex-1 ...">
<button onclick="validateSourcePath()">验证</button>
</div>
```
2. `validateSourcePath()`: 调用 API → 成功后设置 `_uploadSourcePath` + 显示验证结果
3. `clearUpload()` 同时清空源路径输入框
### Step 3: H6 批量重试(纯前端)
**文件**: `web/app/push.html`
1. 进度条区域,`countFailed` 旁加:
```html
<button id="retryAllBtn" class="hidden text-xs text-brand-light hover:underline" onclick="retryAllFailed()">🔄 重试全部失败</button>
```
2. `updateProgressFromWS/Result()` — 有失败时显示 `retryAllBtn`,文本显示失败数
3. 新增 `retryAllFailed()`:
- 收集所有可见的 `srv_retry_{id}` 按钮的 `retryJobId`
- 逐个调用 `POST /api/retries/{id}/retry`
- 显示进度 toast
### Step 4: H4 推送排错面板(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增 `SyncDiagnose(BaseModel)`:
```python
class SyncDiagnose(BaseModel):
server_id: int = Field(..., ge=1)
target_path: Optional[str] = None
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/diagnose`:
- 检查项(依次执行,遇短路可提前返回):
a. SSH 连通性: 尝试 `exec_ssh_command(server, "echo ok", timeout=10)`
b. 磁盘空间: `df -h {target_path}` → 解析 avail/usage%
c. 目标路径: `ls -ld {target_path}` → 解析权限 + owner
d. 写入测试: `touch {target_path}/.nexus_test_$$ && rm {target_path}/.nexus_test_$$`
- 返回: `{ssh_ok, ssh_error?, disk_avail, disk_used_pct, path_exists, path_perms, path_writable, errors[]}`
- 审计日志
**前端**:
1. `showProgress()` 每行加 `srv_diag_{id}` 诊断按钮(仅失败时显示)
2. 新增 `diagnoseServer(serverId)` — 调用 API → 弹出模态框显示检查结果
3. 模态框 `#diagModal`: 4 项检查,每项 ✓/✗ 图标 + 详情
### Step 5: H3 推送对比 Diff 视图(后端+前端)
**后端**:
1. `server/api/schemas.py` — 新增 `FileSyncDiff(BaseModel)`:
```python
class FileSyncDiff(BaseModel):
server_id: int = Field(..., ge=1)
source_path: str = Field(..., min_length=1)
relative_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
```
2. `server/api/sync_v2.py` — 新增 `POST /api/sync/file-diff`:
- 安全: `os.path.realpath(local_file)` 必须 `startswith(source_path)` 且 source_path 必须 `startswith("/tmp/nexus_upload_")`
- 读本地文件: 最多 100KB
- 读远程文件: `cat {shlex.quote(remote_file)}` via SSH,最多 100KB
- diff 计算: Python `difflib.unified_diff()` → 返回 `diff_lines: [{type: "add"|"del"|"ctx", content}]`
- 返回: `{file_name, local_exists, remote_exists, local_size, remote_size, diff_lines, truncated}`
- 审计日志
**前端**:
1. `renderPreviewResult()` verbose 文件列表中每行加 "📄 diff" 按钮
2. 新增 `showFileDiff(serverId, relativePath)` — 调用 API → 弹出 diff 模态框
3. 模态框 `#diffModal`: 文件名 + 左右对比或逐行显示(新增绿底、删除红底、上下文灰底)
## 依赖与配置变更
无新依赖。无数据库 schema 迁移。
## 测试要点
1. H2: 输入 "web" → 下拉只显示名称/域名含 "web" 的服务器
2. H5: 输入 `/tmp/nexus_upload_xxx` → 验证成功 → 可推送
3. H5: 输入 `/etc` → 验证拒绝(敏感路径)
4. H6: 推送 3 台失败 2 台 → 点"重试全部失败" → 2 台重试
5. H4: 推送失败 → 点"🔍 诊断" → SSH/磁盘/权限检查结果
6. H3: 预览 → 文件列表 → 点 "diff" → 显示逐行对比
7. H3: 本地有文件远程无 → 显示为纯新增(全绿)
8. H3: 本地无远程有 → 显示为纯删除(全红)
## 回滚方式
所有改动均为增量(新增函数/端点/前端元素),回滚只需:
1. `git revert` 提交
2. `supervisorctl restart nexus`
@@ -0,0 +1,96 @@
# 2026-05-29 推送页面迭代 Round 2 — 设计文档
## 背景与目标
推送页面 Round 1 已完成 5 项功能(WS 实时进度、分组选择、历史增强、文件操作、推送校验)。用户在实际使用中发现 5 项体验/功能缺陷:
1. **推送失败需手动切页重试** — 失败后需跳到"重试队列"页面,操作割裂
2. **无法识别服务器在线状态** — 选中离线服务器推送必然超时失败,浪费等待时间
3. **推送完成无即时通知** — 批量推送耗时长,用户离开页面后无法得知结果
4. **文件管理器无法预览内容** — 不确定推送的是否是正确文件,需下载验证
5. **同步模式含义不明确** — "增量/全量/校验和"对非运维用户不直观
## 方案对比
### F1 失败自动重试
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 行内重试按钮 | 不离开页面,体验流畅 | 需要从推送结果中获取 retry_job_id |
| B: 自动重试无需按钮 | 全自动 | 可能对临时性错误造成无意义重试 |
| C: 弹窗显示失败列表+批量重试 | 可选择重试哪些 | 交互重 |
**选定方案 A** — 推送失败的行内"🔄 重试"按钮,利用现有 `PushRetryJob` + `/api/retries/` 体系
### F2 在线状态标识
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 选项文字前加🟢/🔴 | 直观,零交互成本 | 无 |
| B: hover 显示状态详情 | 信息更多 | 需额外交互 |
**选定方案 A** — 最简方案,API 已返回 `is_online` 字段
### F3 Telegram 通知
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 推送完成后发一条汇总通知 | 信息集中,不扰 | 延迟感知差 |
| B: 逐台通知 | 实时 | 消息太多 |
| C: 完成时汇总 + 部分失败时额外通知 | 覆盖全面 | 两条消息 |
**选定方案 A** — 推送完成后发一条汇总 Telegram 消息,含成功/失败数、失败服务器名、耗时
### F4 文件内容预览
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 点击文件名弹模态框预览 | 体验流畅 | 需新增后端端点 |
| B: 新 tab 打开 | 简单 | 跳离上下文 |
**选定方案 A** — 新增 `/api/sync/local-file-preview` 端点,前端模态框预览
### F5 同步模式说明
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: radio 下方固定说明文字 | 简单直观 | 占空间 |
| B: hover tooltip | 不占空间 | 可能被忽略 |
| C: 模式旁❓图标hover显示 | 平衡 | 交互多一步 |
**选定方案 A** — radio 组下方增加动态说明文字,切换模式时更新
## 选定方案及理由
所有 5 项均选方案 A(最简方案),理由:
- 利用现有基础设施(重试体系、Telegram 模块、Redis heartbeat
- 改动范围最小,风险最低
- 用户需求明确,无需复杂设计
## 接口/数据模型
### F1: 新增 WS 字段
- `broadcast_sync_progress()` 增加可选参数 `retry_job_id: int = None`
- WS 消息 `{type: "sync_progress", ..., retry_job_id: 123}` (失败时有值)
### F3: 新增 Telegram 函数
- `send_telegram_sync_complete(completed, failed, total, source_path, operator, failed_servers, duration_seconds)`
### F4: 新增 API 端点
- `POST /api/sync/local-file-preview``{path: str}``{name, size, content_base64, truncated, encoding_hint}`
- 新增 Schema: `LocalFilePreview`
## 安全与性能约束
- F1: 重试按钮调已有 `/api/retries/{id}/retry`,已有 JWT 认证 + 审计
- F3: Telegram 消息经 `sanitize_external_message()` 脱敏
- F4: 文件预览限制:仅 `/tmp/nexus_upload_*` 目录 + 最多 4KB + realpath 校验
- F4: 预览端点需 JWT 认证 + 审计日志
## 验收标准
1. F5: 切换同步模式 → 下方说明文字正确更新
2. F2: 服务器列表在线🟢/离线🔴标识正确
3. F1: 推送失败 → 行内出现重试按钮 → 点击可重试
4. F3: 推送完成 → Telegram 收到通知含详情
5. F4: 点击文件名 → 弹出预览框
@@ -0,0 +1,129 @@
# 2026-05-29 推送页面迭代 Round 3 — 设计文档
## 背景与目标
推送页面 Round 1(WS 进度/分组/历史/文件操作/校验)和 Round 2(同步说明/在线标识/失败重试/Telegram 通知/文件预览)已完成全部 10 项功能。基于实际运维场景,仍有 6 项体验/效率缺陷:
1. **推送无法取消** — 推送 50 台发现配错,只能等全部超时,无中止机制
2. **推送页无法直接调度** — 想凌晨推送需跳到调度页重新配置源路径/服务器/模式,操作割裂
3. **无推送模板/收藏** — 常用推送组合每次手动选,重复劳动
4. **推送历史无法筛选** — 历史只有最近 15 条无分页,无法按服务器/状态/日期过滤
5. **无浏览器推送完成通知** — 用户离开页面后不知推送结果(Telegram 有但浏览器没有)
6. **不支持拖拽上传 ZIP** — 文件选择器不够直观
## 方案对比
### G1 推送取消
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: Redis 取消标记 + 引擎轮询 | 简单可靠,无进程间通信 | 最多延迟一个 rsync 周期(5min)才取消 |
| B: asyncio.Task.cancel() | 即时取消 | 多 worker 下无法跨进程取消 |
| C: kill rsync 子进程 | 最直接 | 需跟踪 PID,复杂且风险高 |
**选定方案 A** — Redis `sync:cancel:{batch_id}` 键,sync_engine 的 `_rsync_push()` 每次执行前检查。前端显示"取消推送"按钮,取消后已完成的保留结果,未启动的跳过。简单可靠,无进程间通信问题。
### G2 推送页面直接调度
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 推送按钮旁加"定时推送"按钮 | 复用当前表单配置 | 需新增调度 API 调用 |
| B: 调度选择器嵌入推送页 | 更灵活 | 页面变重 |
**选定方案 A** — "开始推送"按钮旁增加"⏰ 定时推送"按钮,弹出时间选择器后直接调用 `POST /api/schedules/`。推送页已有所需全部字段(server_ids, source_path, sync_mode)。
### G3 推送模板/收藏
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 浏览器 localStorage | 无后端改动 | 仅本机可见 |
| B: 后端 PushTemplate 表 | 跨设备共享 | 需 DB 迁移 + API |
**选定方案 A** — localStorage 存储推送模板。模板包含名称、源路径、目标路径、同步模式、服务器 ID 列表。运维人员通常固定在一台电脑操作,localStorage 足够。若未来需跨设备可迁移到后端。
### G4 推送历史筛选
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 后端分页 + 前端筛选下拉 | 大数据量友好 | 需改 API |
| B: 纯前端筛选 | 无后端改动 | 15 条太少无法筛选 |
**选定方案 A** — 后端 `GET /api/servers/logs` 已有 `limit` 参数,增加 `status`/`server_id`/`page` 参数,前端加筛选控件+分页。
### G5 浏览器推送完成通知
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: Notification API | 系统级通知,即使页面在后台也可见 | 需用户授权 |
| B: 页面 title 闪烁 | 无需授权 | 仅标签页可见 |
**选定方案 A** — Web Notification API。推送开始时请求授权,完成时发浏览器通知。降级:授权拒绝时 title 闪烁。
### G6 拖拽上传 ZIP
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 拖拽区域 + click 回退 | 直观 | 需前端改动 |
**选定方案 A** — 将文件上传区改为拖拽区 + click 选择,标准 HTML5 拖拽实现。
## 选定方案及理由
全部选方案 A(最简方案),理由:
- G1: Redis 标记是跨 worker 最可靠的取消方案
- G2: 复用推送表单 + 现有调度 API,零后端新端点
- G3: localStorage 模板无需 DB 迁移,符合运维单人操作场景
- G4: 后端分页是大数据量唯一正确方案
- G5: Notification API 是唯一真正"后台通知"的方案
- G6: 拖拽上传是现代标准 UX
## 接口/数据模型
### G1: 推送取消
- Redis key: `sync:cancel:{batch_id}` (SET, TTL=3600s)
- `sync_engine_v2._rsync_push()`: 执行前检查 Redis key,存在则返回 `{exit_code: -1, stderr: "推送已取消"}`
- `sync_engine_v2.sync_files()`: 每个任务启动前检查取消标记
- 前端: `POST /api/sync/cancel``{batch_id: str}` → 写 Redis key + 审计日志
- WS 消息: 取消的服务器 status=`"cancelled"`
### G2: 推送页面直接调度
- 无新端点,复用 `POST /api/schedules/`
- 前端: "⏰ 定时推送"按钮 → 弹出 `datetime-local` 选择器 → 调用调度 API
### G3: 推送模板
- localStorage key: `nexus_push_templates` — JSON 数组
- 模板结构: `{name, source_path, target_path, sync_mode, server_ids[]}`
- 前端: 模板下拉选择 + 保存/删除按钮
### G4: 历史筛选
- 后端 `GET /api/servers/logs` 增加: `status: Optional[str]`, `server_id: Optional[int]`, `page: int = 1`, `per_page: int = 20`
- 返回增加: `total`, `page`, `pages`
- 前端: 状态/服务器筛选 + 分页控件
### G5: 浏览器通知
- 推送开始时 `Notification.requestPermission()`
- 推送完成时 `new Notification("Nexus 推送完成", {body: "X 成功, Y 失败"})`
- 降级: 授权拒绝时 `document.title` 闪烁
### G6: 拖拽上传
- `<div>` 拖拽区域 + `dragover/drop` 事件
- 保留原有 `<input type="file">` 作为 click 回退
- 拖拽时高亮边框
## 安全与性能约束
- G1: 取消端点需 JWT 认证 + 审计日志;Redis key 有 TTL 防止泄漏
- G2: 复用调度 API,已有 JWT 认证 + 审计
- G3: localStorage 无安全风险(不含密码/密钥)
- G4: 分页查询有索引 `idx_sync_logs_srv_start`
- G5: Notification 需用户主动授权,非强制
- G6: 拖拽文件仍走现有 upload-zip 端点校验
## 验收标准
1. G1: 推送进行中 → 点击"取消推送" → 未启动的服务器跳过 → 已完成的保留结果
2. G2: 配置推送 → 点击"定时推送" → 选择时间 → 调度页出现对应任务
3. G3: 保存模板 → 刷新页面 → 下拉选择模板 → 表单自动填充
4. G4: 历史区筛选状态/服务器 → 分页翻页 → 结果正确
5. G5: 推送完成 → 浏览器弹出通知(页面在后台也可见)
6. G6: 拖拽 ZIP 到上传区 → 文件上传解压成功
@@ -0,0 +1,96 @@
# 2026-05-29 推送页面迭代 Round 4 — 设计文档
## 背景与目标
推送页面已完成 Round 15项)+ Round 25项)+ Round 3(6项)共 16 项功能。实际使用中发现以下 5 项体验/功能缺口:
1. **2000+ 服务器无法快速定位** — 下拉列表滚动查找低效,缺少搜索过滤
2. **推送前无法预览内容差异** — 只知道哪些文件会传输,不知道具体改了什么
3. **推送失败无法快速诊断** — 失败后不知道是 SSH 不通、磁盘满、还是权限问题
4. **源路径仅支持 ZIP 上传** — 服务器已有文件需先下载再上传,重复劳动
5. **批量失败需逐台点重试** — 推送 50 台失败 20 台,需点 20 次重试按钮
## 方案对比
### H2 服务器搜索过滤
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: `<select>` 上方加搜索框 + 实时过滤 | 简单直观,不改变交互模式 | 无 |
| B: 替换为自定义下拉组件(带搜索) | 更灵活 | 改动大,多选交互复杂 |
**选定方案 A** — 在现有 `<select>` 上方加 `<input>` 搜索框,按名称/域名实时过滤选项
### H3 推送对比 Diff 视图
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 预览文件列表中,每行加"查看diff"按钮 | 按需加载,精确 | 需后端端点读远程文件 |
| B: 批量拉取所有文件diff | 一次性展示全貌 | 大量文件时性能差 |
| C: 推送完成后对比 | 有实际结果 | 不是"推送前"预览 |
**选定方案 A** — 预览文件列表中加"查看diff"按钮,点击后请求后端读本地+远程文件内容,前端渲染逐行 diff(新增绿/删除红/修改黄)
### H4 推送排错面板
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 失败行加"🔍 诊断"按钮,弹面板显示检查结果 | 上下文内操作,体验流畅 | 需后端端点 |
| B: 失败后自动诊断 | 全自动 | 每次失败都跑诊断,浪费资源 |
**选定方案 A** — 失败行加"🔍 诊断"按钮,后端端点依次检查 SSH 连通 → 磁盘空间 → 目标路径权限,返回结构化结果
### H5 源路径直接输入
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: ZIP 上传区域旁加"或输入服务器本地路径"输入框 | 不破坏现有流程 | 无 |
| B: 两种模式切换(上传/路径) | 界面清晰 | 增加交互步骤 |
**选定方案 A** — 上传区域下方加一行"或输入源路径"输入框 + "验证路径"按钮,验证后替代上传路径
### H6 批量重试
| 方案 | 优点 | 缺点 |
|------|------|------|
| A: 进度条区域加"重试所有失败"按钮 | 一键操作 | 无 |
| B: 勾选式批量重试 | 灵活选择 | 交互复杂 |
**选定方案 A** — 推送完成后,如果存在失败行,在进度条区域显示"重试全部失败(N台)"按钮
## 选定方案及理由
全部选方案 A(最简方案):
- H2/H5/H6 均为纯前端或轻量前端改动
- H3/H4 需后端新端点,但复用现有 SSH 执行能力
- 改动范围最小,风险最低
## 接口/数据模型
### H3: 新增 API 端点
- `POST /api/sync/file-diff``{server_id, source_path, relative_path, target_path?}``{diff_lines: [{type, content}], file_name, local_exists, remote_exists}`
- 新增 Schema: `FileSyncDiff`
### H4: 新增 API 端点
- `POST /api/sync/diagnose``{server_id, target_path?}``{ssh_ok, disk_info, path_exists, path_writable, errors[]}`
- 新增 Schema: `SyncDiagnose`
### H5: 新增 API 端点
- `POST /api/sync/validate-source-path``{path}``{valid, is_dir, file_count, size_bytes, error?}`
- 新增 Schema: `ValidateSourcePath`
## 安全与性能约束
- H3: 仅允许对比 `/tmp/nexus_upload_*` 目录下的文件(realpath 校验);远程文件通过 SSH cat 读取,限制 100KB
- H4: 诊断命令通过 `shlex.quote()` 防注入;SSH 操作复用连接池
- H5: realpath 校验 + 不允许 `/etc` `/root` `/home` 等敏感路径前缀;仅允许已存在目录
- H3/H4/H5 均需 JWT 认证 + 审计日志
- H2/H6 纯前端,无安全影响
## 验收标准
1. H2: 输入搜索文字 → 服务器下拉列表实时过滤匹配项
2. H3: 预览文件列表 → 点击"diff" → 弹出逐行对比视图
3. H4: 推送失败 → 点击"🔍 诊断" → 弹出面板显示 SSH/磁盘/权限检查结果
4. H5: 输入服务器本地路径 → 点验证 → 成功后可直接推送
5. H6: 推送完成后有失败 → 显示"重试全部失败"按钮 → 点击后所有失败服务器重试
File diff suppressed because one or more lines are too long
@@ -0,0 +1,248 @@
# Nexus 6.0 — 全面代码扫描审计报告 (Round 2 合并版)
**合并来源**: 本地深度扫描 + worktree独立审计
**说明**: 两份独立审计报告去重合并,标注已修复项
---
## P0 — CRITICAL (立即修复)
### C-1: sync_service.py rsync命令 — Shell注入回归
- **文件**: `server/application/services/sync_service.py:195`
- **来源**: 本地审计 BUG-2
- **描述**: rsync命令f-string直接嵌入 `source_path`, `server.username`, `server.domain`, `target_path`,无 `shlex.quote()`。sync_engine_v2.py 已修复(C8),但 sync_service.py 遗漏。恶意用户名如 `root'; rm -rf /;#` 可执行任意命令。
- **修复**: `import shlex;` 所有动态参数 `shlex.quote()`
### C-2: agent.py exec — 命令注入无防护(RCE)
- **文件**: `server/api/agent.py:193-209`
- **来源**: 两份报告均发现 (本地 SEC-1 / worktree S-02)
- **描述**: `/api/agent/exec` 直接 `create_subprocess_shell(command)` 执行任意命令,无白名单/危险命令检测。仅全局API Key保护。Nexus被攻破后可对所有Agent执行任意命令。
- **修复**: 添加与 script_service.py 一致的危险命令检测;命令白名单;`secrets.compare_digest` 替代 `==` 比较API Key
### C-3: WebSSH 横向越权(IDOR
- **文件**: `server/api/webssh.py:82-97`
- **来源**: worktree S-01/B-01
- **描述**: JWT无 `server_id` claim时不校验路径 `server_id`,任意已登录管理员可改URL连任意服务器SSH。`_verify_webssh_token()` 返回 `server_id` 但未与路径参数比较。
- **修复**: 签发WebSSH专用短期JWT(必含 `server_id`);验证时比对 token_server_id == path server_id;不匹配则 `close(4003)`
---
## P1 — HIGH (本周修复)
### H-1: PUT/POST端点 setattr 无白名单 — 任意字段注入
- **文件**: `servers.py:121-123`, `settings.py:89-91`, `assets.py:52-54,109-111`, `scripts.py:69-71`, `servers.py:106`
- **来源**: 本地审计 BUG-4/5
- **描述**: 所有PUT端点 `for key, value in payload.items(): setattr(obj, key, value)`,攻击者可注入 `is_active=False`, `password_hash="..."`, `is_online=True` 等敏感字段。POST端点 `Server(**payload)` 同理。
- **修复**: 定义每端点允许字段白名单;或使用Pydantic请求模型
### H-2: settings/assets 端点 — 无JWT认证
- **文件**: `server/api/settings.py`, `server/api/assets.py`
- **来源**: 本地审计 SEC-2
- **描述**: 所有端点使用 `Depends(get_db)` 而非 `Depends(get_current_admin)`。任何人可读/改系统设置、创建/删除节点、查看SSH会话和命令日志。
- **修复**: 所有端点添加 `admin: Admin = Depends(get_current_admin)` 依赖
### H-3: agent.py proc.kill() — 僵尸进程泄漏
- **文件**: `server/api/agent.py:223`
- **来源**: 两份报告均发现 (本地 BUG-1 / worktree B-05)
- **描述**: `asyncio.TimeoutError` 分支 `proc.kill()` 后无 `await proc.wait()`,产生僵尸进程。
- **修复**: `proc.kill()` 后加 `await proc.wait()`
### H-4: refresh_token 并发重放攻击
- **文件**: `server/application/services/auth_service.py:105-127`
- **来源**: 本地审计 BUG-3
- **描述**: 并发请求使用同一refresh_token都能成功获取新token。违反rotation安全原则。
- **修复**: 数据库层面加唯一约束或应用层加锁
### H-5: N+1 Redis查询 — 2000+服务器性能瓶颈
- **文件**: `server/api/servers.py:40-65`
- **来源**: 两份报告均发现 (本地 PERF-1 / worktree P-02)
- **描述**: 每台服务器单独 `redis.hgetall()`2000+服务器=2000+次Redis往返。
- **修复**: Redis pipeline 批量获取
### H-6: Agent心跳直写MySQL — 与文档矛盾
- **文件**: `server/api/agent.py:172`
- **来源**: worktree B-04/P-01
- **描述**: 每次心跳 `service.update_heartbeat()` + commit,与文档"Redis实时+10min批量落MySQL"矛盾。2000+服务器×60s = 高MySQL负载。
- **修复**: 心跳仅写Redis,由 `heartbeat_flush.py` 批量刷库
### H-7: API Key reveal 端点
- **文件**: `server/api/settings.py` (如存在 `/api-key/reveal`)
- **来源**: worktree S-03
- **描述**: 对已登录管理员返回完整全局API_KEY,一旦XSS即可窃取。
- **修复**: 禁止回读;仅轮换时展示一次
### H-8: TOTP disable 无二次验证
- **文件**: `server/api/auth.py:151-165`
- **来源**: worktree S-09
- **描述**: `totp/disable` 仅需JWT,无密码/TOTP二次验证。JWT被盗后攻击者可直接关闭双因素。
- **修复**: 要求 `current_password` + 有效TOTP码
### H-9: 全局无JWT中间件 — 新路由易漏认证
- **文件**: 架构层面
- **来源**: worktree E-01
- **描述**: 每路由靠 `Depends(get_current_admin)`,新路由开发易遗漏。settings/assets 已漏。
- **修复**: `main.py` 默认拒绝 + 白名单(install/health/agent/auth/login
---
## P2 — MEDIUM (迭代修复)
### M-1: server_id 类型验证缺失
- **文件**: `server/api/agent.py:103`
- **来源**: 本地审计 BUG-6
- **修复**: `try: server_id = int(payload["server_id"]); except: raise 400`
### M-2: get_optional_admin session泄漏
- **文件**: `server/api/auth_jwt.py:128-133`
- **来源**: 本地审计 BUG-7
- **修复**: 接收Request参数,通过 `_get_request_session(request)` 获取session
### M-3: sshpass环境变量未设置
- **文件**: `server/application/services/sync_service.py:188-193`
- **来源**: 本地审计 BUG-8
- **修复**: exec_command调用时传递 `env={"SSHPASS": password}`
### M-4: X-Forwarded-For IP伪造
- **文件**: `server/api/auth.py:19-24`
- **来源**: 本地审计 SEC-3
- **修复**: 优先使用 `X-Real-IP` headerNginx设置);或使用 `X-Forwarded-For` 最后一个值
### M-5: known_hosts=None — SSH中间人攻击
- **文件**: `server/infrastructure/ssh/asyncssh_pool.py:145`
- **来源**: 本地审计 RISK-1
- **修复**: 实现known_hosts缓存机制
### M-6: 异常信息泄露服务器拓扑
- **文件**: `server/infrastructure/ssh/asyncssh_pool.py:164`
- **来源**: 本地审计 RISK-2
- **修复**: 日志记录完整错误,返回客户端隐藏内部地址
### M-7: TOTP brute-force 无限制
- **文件**: `server/application/services/auth_service.py:179-191`
- **来源**: 本地审计 RISK-3
- **修复**: TOTP验证添加速率限制(5次失败后锁定5分钟)
### M-8: payload: dict → Pydantic模型
- **文件**: 多处API路由
- **来源**: 本地审计 STD-1
- **修复**: 为每个端点创建Pydantic请求模型
### M-9: 在线统计与列表数据源不一致
- **文件**: `server/api/servers.py`
- **来源**: worktree B-03
- **修复**: 统计接口与列表共用Redis数据源
### M-10: login_attempts 缺少复合索引
- **文件**: `server/infrastructure/database/admin_repo.py:48-58`
- **来源**: 本地审计 PERF-3
- **修复**: 添加 `Index('idx_login_attempts_check', 'username', 'ip_address', 'success', 'attempted_at')`
### M-11: audit_logs/settings 无分页上限
- **文件**: `server/api/settings.py:23-27`
- **来源**: 本地审计 PERF-2
- **修复**: 添加limit上限保护
### M-12: install路由生产环境仍挂载
- **文件**: `server/api/install.py`
- **来源**: worktree S-08
- **修复**: 安装完成后卸载路由或IP白名单+一次性token
### M-13: API Key比较非常量时间
- **文件**: `server/api/agent.py:37`
- **来源**: worktree S-12
- **修复**: `secrets.compare_digest(x_api_key, settings.API_KEY)`
### M-14: 告警冷却多worker重复Telegram
- **文件**: `server/api/websocket.py`
- **来源**: worktree P-03
- **修复**: Redis SETNX + TTL 去重
### M-15: AES-CBC遗留兼容代码
- **文件**: `server/infrastructure/database/crypto.py:59-75`
- **来源**: worktree R-01
- **修复**: 迁移期后移除CBC;统一Fernet
---
## P3 — LOW (优化改进)
### L-1: `__import__("datetime")` 应改为正常import
- **文件**: `server/application/services/sync_service.py:168,200,212,219,228`
- **来源**: 本地审计 STD-2
### L-2: infrastructure层直接import到api层
- **文件**: `server/api/servers.py:17`, `server/api/agent.py:22-23`
- **来源**: 本地审计 STD-3
### L-3: Fernet key无rotation机制
- **文件**: `server/infrastructure/database/crypto.py:16-21`
- **来源**: 本地审计 SEC-4
### L-4: Redis overlay逻辑重复(DRY)
- **文件**: `server/api/servers.py:44-61` vs `:80-95`
- **来源**: 本地审计 OPT-2
### L-5: API响应含SSH username字段
- **文件**: `server/api/servers.py:206`
- **来源**: 本地审计 OPT-3
### L-6: 错误消息中英文混用
- **文件**: 多处
- **来源**: 本地审计 UX-1
### L-7: refresh token单设备限制
- **文件**: `server/domain/models/__init__.py:157-158`
- **来源**: 本地审计 SCALE-2
### L-8: batch_push顺序获取server
- **文件**: `server/application/services/sync_service.py:67-72`
- **来源**: 本地审计 PERF-4
### L-9: 前端esc()重复定义
- **文件**: 多处 `web/app/*.html`
- **来源**: worktree C-02
### L-10: 前端空catch吞错误
- **文件**: `web/app/api.js`
- **来源**: worktree U-01
### L-11: install.py同步subprocess阻塞事件循环
- **文件**: `server/api/install.py:283-284`
- **来源**: worktree C-03
### L-12: 前端15页独立内联脚本无组件复用
- **文件**: `web/app/*.html`
- **来源**: worktree E-04
---
## 汇总
| 严重度 | 数量 | 类别覆盖 |
|--------|------|----------|
| **P0 CRITICAL** | 3 | Shell注入回归、Agent RCE、WebSSH IDOR |
| **P1 HIGH** | 9 | setattr注入、无JWT认证、僵尸进程、token重放、N+1 Redis、心跳写库矛盾、API Key reveal、TOTP disable、全局JWT |
| **P2 MEDIUM** | 15 | 类型验证、session泄漏、sshpass、IP伪造、SSH MITM、信息泄露、TOTP brute-force、Pydantic、数据源不一致、索引缺失、分页、install路由、compare_digest、告警去重、AES遗留 |
| **P3 LOW** | 12 | import规范、架构违规、key rotation、DRY、字段脱敏、i18n、多设备、批量查询、前端esc统一、错误处理、install阻塞、前端复用 |
### 修复执行顺序
**第一批(P0 — 安全致命)**:
1. C-1: sync_service.py shlex.quote()
2. C-2: agent exec 危险命令检测 + compare_digest
3. C-3: WebSSH server_id绑定验证
**第二批(P1 — 安全+性能)**:
4. H-1: setattr白名单/Pydantic模型
5. H-2: settings/assets JWT认证
6. H-9: 全局JWT中间件
7. H-3: proc.kill() + await proc.wait()
8. H-4: refresh_token并发保护
9. H-5: Redis pipeline
10. H-6: 心跳仅写Redis
11. H-7: API Key reveal禁止
12. H-8: TOTP disable二次验证
**第三批(P2 — 纵深防御)**: M-1 ~ M-15
**第四批(P3 — 优化)**: L-1 ~ L-12
+193
View File
@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus 6.0 Docs</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script>
tailwind.config={darkMode:'class',theme:{extend:{fontFamily:{sans:['Inter','system-ui','-apple-system','sans-serif'],mono:['JetBrains Mono','Fira Code','Consolas','monospace']},colors:{v:{border:'rgba(255,255,255,0.08)',hover:'rgba(255,255,255,0.06)',active:'rgba(255,255,255,0.10)',dim:'#666',muted:'#888',text:'#ccc',heading:'#ededed'}}}}}
</script>
<style>
[x-cloak]{display:none!important}
.scroll-area{scrollbar-width:thin;scrollbar-color:transparent transparent}
.scroll-area:hover{scrollbar-color:#333 transparent}
.scroll-area::-webkit-scrollbar{width:5px}
.scroll-area::-webkit-scrollbar-thumb{background:transparent;border-radius:9px}
.scroll-area:hover::-webkit-scrollbar-thumb{background:#333}
.scroll-area::-webkit-scrollbar-track{background:transparent}
.prose-v h1{@apply text-[28px] font-bold tracking-[-0.02em] text-v-heading mb-8}
.prose-v h1::after{content:'';display:block;margin-top:20px;border-bottom:1px solid rgba(255,255,255,0.08)}
.prose-v h2{@apply text-[20px] font-semibold tracking-[-0.01em] text-v-heading mt-12 mb-4}
.prose-v h2::after{content:'';display:block;margin-top:12px;border-bottom:1px solid rgba(255,255,255,0.06)}
.prose-v h3{@apply text-[16px] font-semibold text-v-heading mt-8 mb-3}
.prose-v h4{@apply text-[14px] font-semibold text-v-text mt-6 mb-2}
.prose-v p{@apply mb-5 leading-[1.8] text-v-muted text-[14px]}
.prose-v a{@apply text-blue-400 hover:text-blue-300 underline underline-offset-2 decoration-blue-400/30 hover:decoration-blue-300/50 transition-colors}
.prose-v strong{@apply text-v-heading font-semibold}
.prose-v code{@apply bg-white/[0.06] px-[5px] py-[2px] rounded-[4px] text-[13px] font-mono text-[#c9a0ff]}
.prose-v pre{@apply relative bg-[#080808] border border-v-border rounded-[8px] p-0 overflow-hidden mb-6}
.prose-v pre::before{content:attr(data-lang);@apply absolute top-0 right-0 text-[10px] font-mono text-white/20 px-3 py-2 uppercase tracking-wider select-none}
.prose-v pre code{@apply bg-transparent p-5 block text-[13px] leading-[1.7] text-[#aaa] overflow-x-auto}
.prose-v blockquote{@apply border-l-2 border-blue-500/40 pl-5 py-2 mb-5 bg-blue-500/[0.03] text-v-muted text-[14px]}
.prose-v table{@apply w-full text-[13px] mb-6 border border-v-border rounded-lg overflow-hidden}
.prose-v thead{@apply bg-white/[0.03]}
.prose-v th{@apply px-4 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-v-dim border-b border-v-border}
.prose-v td{@apply px-4 py-2.5 border-b border-white/[0.04] text-v-muted}
.prose-v tr:last-child td{@apply border-b-0}
.prose-v ul{@apply list-none pl-0 mb-5 space-y-2}
.prose-v ol{@apply list-none pl-0 mb-5 space-y-2}
.prose-v li{@apply text-v-muted text-[14px] pl-5 relative}
.prose-v li::before{@apply absolute left-0 text-v-dim;content:''}
.prose-v ol>li{counter-increment:item}
.prose-v ol>li::before{content:counter(item) '.'}
.prose-v hr{@apply border-v-border my-10}
.prose-v img{@apply max-w-full rounded-lg border border-v-border}
.nav-item{@apply block px-3 py-[6px] text-[13px] text-v-dim cursor-pointer truncate transition-all duration-100 rounded-md}
.nav-item:hover{@apply text-v-text bg-v-hover}
.nav-item.active{@apply text-white bg-v-active font-medium}
.toc-link{@apply block text-[12px] py-[5px] text-v-dim hover:text-v-text truncate transition-colors duration-100}
.toc-link.active{@apply text-white font-medium}
html:not(.dark) .prose-v h1,html:not(.dark) .prose-v h2,html:not(.dark) .prose-v h3{@apply text-gray-900}
html:not(.dark) .prose-v h4,html:not(.dark) .prose-v strong{@apply text-gray-800}
html:not(.dark) .prose-v p,html:not(.dark) .prose-v li,html:not(.dark) .prose-v td{@apply text-gray-500}
html:not(.dark) .prose-v h1::after,html:not(.dark) .prose-v h2::after{@apply border-gray-200}
html:not(.dark) .prose-v pre{@apply bg-gray-50 border-gray-200}
html:not(.dark) .prose-v pre code{@apply text-gray-700}
html:not(.dark) .prose-v code{@apply bg-gray-100 text-purple-700}
html:not(.dark) .prose-v th{@apply text-gray-400 border-gray-200}
html:not(.dark) .prose-v thead{@apply bg-gray-50}
html:not(.dark) .prose-v td{@apply border-gray-100}
html:not(.dark) .nav-item:hover{@apply bg-gray-100}
html:not(.dark) .nav-item.active{@apply bg-gray-200 text-gray-900}
</style>
</head>
<body class="h-screen overflow-hidden bg-black text-gray-300 font-sans antialiased" x-data="docApp()" x-init="init()" x-cloak>
<div x-show="sidebarOpen" x-transition.opacity.duration.150ms class="fixed inset-0 bg-black/70 backdrop-blur-sm z-30 lg:hidden" @click="sidebarOpen=false"></div>
<div class="flex h-full">
<!-- Sidebar -->
<aside :class="sidebarOpen?'translate-x-0':'-translate-x-full lg:translate-x-0'" class="fixed lg:relative z-40 w-[280px] h-full flex flex-col border-r border-v-border bg-black transition-transform duration-200 ease-out">
<div class="px-5 pt-6 pb-5 shrink-0">
<div class="flex items-center gap-3">
<div class="w-7 h-7 rounded-md bg-gradient-to-tr from-blue-500 via-violet-500 to-fuchsia-500 flex items-center justify-center text-white text-xs font-bold shadow-lg shadow-violet-500/20">N</div>
<div><h1 class="text-[13px] font-semibold text-white tracking-tight">Nexus 6.0</h1><p class="text-[11px] text-v-dim">Documentation</p></div>
</div>
</div>
<div class="px-4 pb-4 shrink-0">
<div class="relative group">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-v-dim group-focus-within:text-v-muted transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input x-model="searchQuery" @keydown.escape="searchQuery=''" type="text" placeholder="Quick search..." class="w-full pl-8 pr-8 py-[7px] text-[13px] rounded-md border border-v-border bg-transparent text-gray-300 placeholder-v-dim focus:outline-none focus:border-white/20 transition-all">
<kbd class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-v-dim border border-v-border rounded px-1.5 py-[1px] font-mono hidden sm:block">⌘K</kbd>
</div>
</div>
<div class="mx-4 h-px bg-v-border"></div>
<nav class="flex-1 overflow-y-auto scroll-area py-3 px-3">
<template x-for="group in filteredGroups" :key="group.name">
<div class="mb-0.5">
<button @click="group.collapsed=!group.collapsed" class="w-full flex items-center gap-2 px-2 py-[7px] text-[11px] font-medium uppercase tracking-[0.08em] text-v-dim hover:text-v-muted transition-colors rounded-md">
<svg :class="group.collapsed?'-rotate-90':''" class="w-[10px] h-[10px] transition-transform duration-150" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
<span x-text="group.icon+' '+group.name"></span>
<span class="ml-auto text-[10px] text-v-dim/60 font-mono" x-text="group.files.length"></span>
</button>
<div x-show="!group.collapsed" x-collapse.duration.150ms>
<div class="ml-1 space-y-px">
<template x-for="f in group.files" :key="f.path">
<div @click="openDoc(f.path)" :title="f.path" :class="{'active':activePath===f.path}" class="nav-item" x-text="f.label"></div>
</template>
</div>
</div>
</div>
</template>
</nav>
<div class="px-4 py-3 border-t border-v-border shrink-0 flex items-center justify-between">
<span class="text-[11px] text-v-dim/50 font-mono" x-text="totalDocs+' docs'"></span>
<button @click="toggleTheme()" class="p-1 rounded-md text-v-dim hover:text-v-muted hover:bg-v-hover transition-all" title="Toggle theme">
<svg x-show="darkMode" class="w-[14px] h-[14px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"/></svg>
<svg x-show="!darkMode" class="w-[14px] h-[14px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 7.5c0 5.385 4.365 9.75 9.75 9.75a9.726 9.726 0 006.002-2.248z"/></svg>
</button>
</div>
</aside>
<!-- Main -->
<main class="flex-1 flex flex-col min-w-0">
<header class="shrink-0 h-11 flex items-center gap-3 px-5 border-b border-v-border bg-black/90 backdrop-blur-md z-10">
<button @click="sidebarOpen=!sidebarOpen" class="lg:hidden p-1 rounded-md text-v-dim hover:text-v-muted hover:bg-v-hover transition-all">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>
</button>
<nav class="flex items-center gap-1 text-[12px] overflow-hidden font-mono">
<a href="#" @click.prevent="goHome()" class="text-v-dim hover:text-v-muted transition-colors">~</a>
<template x-for="(part,i) in breadcrumb" :key="i">
<span class="text-v-dim/40 mx-0.5">/</span>
<span :class="i===breadcrumb.length-1?'text-v-text':'text-v-dim'" x-text="part"></span>
</template>
</nav>
<div class="ml-auto text-[11px] text-v-dim/40 font-mono hidden sm:block" x-text="fileStats"></div>
</header>
<div class="flex-1 flex overflow-hidden">
<div class="flex-1 overflow-y-auto scroll-area" id="main-scroll">
<div class="max-w-[680px] mx-auto px-6 py-14 lg:px-0">
<div x-show="!activePath" class="flex flex-col items-center justify-center py-44 text-center select-none">
<div class="w-14 h-14 rounded-xl bg-gradient-to-tr from-blue-500/10 via-violet-500/10 to-fuchsia-500/10 border border-v-border flex items-center justify-center mb-8">
<svg class="w-6 h-6 text-v-dim" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
</div>
<h2 class="text-xl font-semibold text-white tracking-tight">Nexus Documentation</h2>
<p class="text-[13px] text-v-dim mt-2">Select a document or press <kbd class="text-[11px] border border-v-border rounded px-1.5 py-[1px] font-mono ml-1">⌘K</kbd></p>
</div>
<div x-show="activePath" class="prose-v" x-html="renderedContent"></div>
</div>
</div>
<aside x-show="tocItems.length>1" x-transition.duration.150ms class="hidden xl:block w-[180px] shrink-0 border-l border-v-border overflow-y-auto scroll-area py-14 pl-5 pr-3">
<div class="text-[10px] font-medium uppercase tracking-[0.1em] text-v-dim mb-4">On this page</div>
<nav class="space-y-0.5 border-l border-v-border/50 ml-0.5">
<template x-for="item in tocItems" :key="item.id">
<a :href="'#'+item.id" @click.prevent="scrollToHeading(item.id)" :class="['toc-link pl-3',{'active border-l-2 -ml-px border-white/30':activeToc===item.id}]" :style="activeToc!==item.id?'padding-left:13px':''" x-text="item.text"></a>
</template>
</nav>
</aside>
</div>
</main>
</div>
<script>
// __DATA_PLACEHOLDER__
function docApp(){
return {
navGroups:NAV_GROUPS,contentMap:CONTENT_MAP,
searchQuery:'',activePath:null,renderedContent:'',
breadcrumb:['docs'],tocItems:[],activeToc:null,darkMode:true,sidebarOpen:false,
get totalDocs(){return Object.keys(this.contentMap).length},
get fileStats(){if(!this.activePath)return'';const c=this.contentMap[this.activePath]||'';return c.split('\n').length+'L · '+(c.length/1024).toFixed(1)+'K'},
get filteredGroups(){
const q=this.searchQuery.toLowerCase();
return this.navGroups.map(g=>{const files=q?g.files.filter(f=>f.path.toLowerCase().includes(q)||(this.contentMap[f.path]||'').toLowerCase().includes(q)):g.files;return{...g,files,collapsed:files.length===0?true:g.collapsed}}).filter(g=>g.files.length>0);
},
init(){
const s=localStorage.getItem('nexus-docs-theme');this.darkMode=s?s==='dark':true;this.applyTheme();
document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='k'){e.preventDefault();document.querySelector('input[type=text]')?.focus()}});
this.$nextTick(()=>{const el=document.getElementById('main-scroll');if(el)el.addEventListener('scroll',()=>this.updateActiveToc())});
},
toggleTheme(){this.darkMode=!this.darkMode;this.applyTheme();localStorage.setItem('nexus-docs-theme',this.darkMode?'dark':'light')},
applyTheme(){document.documentElement.classList.toggle('dark',this.darkMode);document.body.className=this.darkMode?'h-screen overflow-hidden bg-black text-gray-300 font-sans antialiased':'h-screen overflow-hidden bg-white text-gray-700 font-sans antialiased'},
openDoc(path){this.activePath=path;this.sidebarOpen=false;this.renderedContent=marked.parse(this.contentMap[path]||'*Not found*');this.breadcrumb=path.replace('.md','').split('/');this.$nextTick(()=>{this.buildToc();document.getElementById('main-scroll')?.scrollTo(0,0)})},
goHome(){this.activePath=null;this.renderedContent='';this.breadcrumb=['docs'];this.tocItems=[]},
buildToc(){const c=document.querySelector('.prose-v');if(!c){this.tocItems=[];return}this.tocItems=Array.from(c.querySelectorAll('h1,h2,h3,h4')).map((h,i)=>{h.id='toc-'+i;return{id:'toc-'+i,text:h.textContent,level:parseInt(h.tagName[1])}});this.activeToc=this.tocItems[0]?.id||null},
scrollToHeading(id){document.getElementById(id)?.scrollIntoView({behavior:'smooth',block:'start'});this.activeToc=id},
updateActiveToc(){for(let i=this.tocItems.length-1;i>=0;i--){const el=document.getElementById(this.tocItems[i].id);if(el&&el.getBoundingClientRect().top<100){this.activeToc=this.tocItems[i].id;return}}this.activeToc=this.tocItems[0]?.id||null}
};
}
const renderer=new marked.Renderer();
renderer.code=function(code,lang){const a=lang?` data-lang="${lang}"`:'';const h=lang&&hljs.getLanguage(lang)?hljs.highlight(code,{language:lang}).value:hljs.highlightAuto(code).value;return`<pre${a}><code>${h}</code></pre>`};
marked.setOptions({renderer,breaks:true,gfm:true});
</script>
</body>
</html>
+15 -11
View File
@@ -131,7 +131,18 @@ async def git_log(count: int = 10):
@server.tool()
async def deploy():
"""同步最新代码到运行目录并重启服务(需通过 pre-deploy 门控检查)"""
# ── Gate Check ──
# ── Pull latest code first ──
try:
pull = subprocess.run(
f"cd {DEPLOY_DIR} && git fetch --all 2>&1 && git reset --hard origin/main 2>&1",
shell=True, capture_output=True, text=True, timeout=30,
)
if pull.returncode != 0:
return f"🚫 Git pull failed:\n\n{pull.stdout}\n{pull.stderr}"
except Exception as e:
return f"🚫 Git pull error: {e}"
# ── Gate Check (after pull so files are up to date) ──
gate_script = os.path.join(DEPLOY_DIR, "deploy", "pre_deploy_check.sh")
if os.path.exists(gate_script):
try:
@@ -148,17 +159,10 @@ async def deploy():
# Gate script not on server yet — warn but allow (bootstrap scenario)
pass
# ── Deploy ──
# ── Restart service ──
try:
cmds = [
f"cd {DEPLOY_DIR} && git fetch --all 2>&1 && git reset --hard origin/master 2>&1",
"supervisorctl restart nexus 2>&1",
]
result = []
for cmd in cmds:
p = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
result.append(p.stdout or p.stderr or "OK")
return "\n---\n".join(result)
p = subprocess.run("supervisorctl restart nexus 2>&1", shell=True, capture_output=True, text=True, timeout=30)
return f"Pull:\n{pull.stdout}\n---\nRestart:\n{p.stdout or p.stderr or 'OK'}"
except Exception as e:
return str(e)
+2 -2
View File
@@ -319,8 +319,8 @@ async def script_job_callback(
target_type="script_execution",
target_id=result["execution_id"],
detail=(
f"server_id={payload.server_id} job_id={payload.job_id} "
f"exit_code={payload.exit_code} status={result.get('status')}"
f"服务器ID={payload.server_id} 任务ID={payload.job_id} "
f"退出码={payload.exit_code} 状态={result.get('status')}"
),
))
+6 -6
View File
@@ -60,7 +60,7 @@ async def create_platform(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_platform",
target_type="platform", target_id=created.id,
detail=f"name={created.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _platform_to_dict(created)
@@ -90,7 +90,7 @@ async def update_platform(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_platform",
target_type="platform", target_id=id,
detail=f"name={updated.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _platform_to_dict(updated)
@@ -116,7 +116,7 @@ async def delete_platform(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_platform",
target_type="platform", target_id=id,
detail=f"name={platform.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={platform.name}", ip_address=request.client.host if request.client else "",
))
@@ -164,7 +164,7 @@ async def create_node(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_node",
target_type="node", target_id=created.id,
detail=f"name={created.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _node_to_dict(created)
@@ -194,7 +194,7 @@ async def update_node(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_node",
target_type="node", target_id=id,
detail=f"name={updated.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _node_to_dict(updated)
@@ -220,7 +220,7 @@ async def delete_node(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_node",
target_type="node", target_id=id,
detail=f"name={node.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={node.name}", ip_address=request.client.host if request.client else "",
))
+10 -4
View File
@@ -289,12 +289,18 @@ async def change_password(
new_hash = bcrypt.hashpw(payload.new_password.encode(), bcrypt.gensalt()).decode()
current_admin.password_hash = new_hash
# Security: invalidate all existing sessions (refresh tokens + JWT updated_at check)
# Security: invalidate all existing sessions (refresh tokens + JWT token_version)
current_admin.token_version += 1
current_admin.jwt_refresh_token = None
current_admin.jwt_token_expires = None
await admin_repo.update(current_admin)
# Clear all refresh tokens from Redis (all devices logged out)
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
await redis.delete(f"refresh_tokens:{admin.id}")
except Exception:
logger.warning(f"Failed to clear refresh tokens from Redis for admin {admin.id}", exc_info=True)
# Audit log with client IP
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
@@ -303,7 +309,7 @@ async def change_password(
action="change_password",
target_type="admin",
target_id=admin.id,
detail=f"Password changed for {admin.username} (all sessions invalidated)",
detail=f"{admin.username} 修改密码(所有会话已失效)",
ip_address=ip_address,
))
+30 -11
View File
@@ -1,26 +1,40 @@
"""Nexus — Health Check Endpoint
External monitoring endpoint for Shell health_monitor.sh and Supervisor.
Security: Only returns minimal status ("ok") for external probes.
Detailed health diagnostics require admin JWT auth via /health/detail.
"""
from fastapi import APIRouter
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from server.api.auth_jwt import get_current_admin
from server.domain.models import Admin
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.redis.client import get_redis
from server.api.websocket import manager as ws_manager
logger = logging.getLogger("nexus.health")
router = APIRouter()
@router.get("/health")
async def health_check():
"""Health check endpoint — used by external Shell script to verify Python is alive.
"""Minimal health check — used by Shell health_monitor.sh.
Only returns {"status": "ok"} if Python is alive.
No infrastructure details exposed to unauthenticated callers.
"""
return {"status": "ok"}
Returns:
- python: always "ok" (if this endpoint responds, Python is alive)
- redis: "ok" or "unavailable"
- mysql: "ok" or "error"
- websocket_clients: number of connected WS clients
@router.get("/health/detail")
async def health_detail(admin: Admin = Depends(get_current_admin)):
"""Detailed health diagnostics — requires admin JWT authentication.
Returns Redis/MySQL/WebSocket status for admin dashboard use.
"""
checks = {"python": "ok"}
@@ -29,18 +43,23 @@ async def health_check():
redis = get_redis()
await redis.ping()
checks["redis"] = "ok"
except Exception:
except Exception as e:
logger.error(f"Redis health check failed: {e}")
checks["redis"] = "unavailable"
# MySQL check
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
checks["mysql"] = "ok"
except Exception:
checks["mysql"] = "ok"
except Exception as e:
logger.error(f"MySQL health check failed: {e}")
checks["mysql"] = "error"
# WebSocket connections count
checks["websocket_clients"] = ws_manager.client_count
try:
checks["websocket_clients"] = await ws_manager.global_client_count()
except Exception:
checks["websocket_clients"] = ws_manager.client_count
return {"status": "ok", "checks": checks}
+1
View File
@@ -469,6 +469,7 @@ async def init_db(req: InitDbRequest):
settings_kv = {
"api_key": api_key,
"secret_key": secret_key,
"api_base_url": site_url,
"system_name": "Nexus",
"system_title": "Nexus — 服务器运维管理平台",
"db_pool_size": str(pool_size),
+49
View File
@@ -135,6 +135,55 @@ class SyncBrowse(BaseModel):
path: str = Field("/", min_length=1)
class SyncBrowseLocal(BaseModel):
"""Browse a local directory on the Nexus server (for upload file manager)."""
path: str = Field(..., min_length=1)
class LocalFileOperation(BaseModel):
"""Delete or rename a file/directory in the local upload staging area."""
operation: str = Field(..., pattern="^(delete|rename)$")
path: str = Field(..., min_length=1)
new_name: Optional[str] = None # required for rename
class LocalFilePreview(BaseModel):
"""Preview file content in the local upload staging area."""
path: str = Field(..., min_length=1)
class ValidateSourcePath(BaseModel):
"""Validate a local directory path on the Nexus server as a push source."""
path: str = Field(..., min_length=1)
class SyncDiagnose(BaseModel):
"""Diagnose why a push failed for a specific server."""
server_id: int = Field(..., ge=1)
target_path: Optional[str] = None
class FileSyncDiff(BaseModel):
"""Compare a local file with its remote counterpart to show push diff."""
server_id: int = Field(..., ge=1)
source_path: str = Field(..., min_length=1)
relative_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
class SyncCancel(BaseModel):
"""Cancel an in-progress push by batch_id."""
batch_id: str = Field(..., min_length=1, max_length=32)
class SyncVerify(BaseModel):
"""Post-push verification: compare local source with remote target via md5sum."""
server_ids: List[int] = Field(..., min_length=1)
source_path: str = Field(..., min_length=1)
target_path: Optional[str] = None
max_files: int = Field(200, ge=1, le=500)
class FileOperation(BaseModel):
"""Remote file operation via SSH exec (delete / rename / mkdir)."""
server_id: int = Field(..., ge=1)
+5 -5
View File
@@ -81,7 +81,7 @@ async def create_credential(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_credential",
target_type="credential", target_id=created.id,
detail=f"name={created.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _credential_to_dict(created)
@@ -122,7 +122,7 @@ async def update_credential(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_credential",
target_type="credential", target_id=id,
detail=f"name={updated.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _credential_to_dict(updated)
@@ -281,7 +281,7 @@ async def create_script(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_script",
target_type="script", target_id=created.id,
detail=f"name={created.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _script_to_dict(created)
@@ -311,7 +311,7 @@ async def update_script(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_script",
target_type="script", target_id=id,
detail=f"name={updated.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _script_to_dict(updated)
@@ -337,7 +337,7 @@ async def delete_script(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_script",
target_type="script", target_id=id,
detail=f"name={script.name}", ip_address=request.client.host if request.client else "",
detail=f"名称={script.name}", ip_address=request.client.host if request.client else "",
))
+397 -27
View File
@@ -27,6 +27,87 @@ from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import shlex
def _sudo_wrap(cmd: str, ssh_user: str) -> str:
"""Wrap a command with sudo setup/teardown for non-root SSH users.
For non-root users, pre-configures NOPASSWD sudo before the command
and cleans up the sudoers entry afterwards. For root, returns cmd as-is.
"""
if ssh_user == "root":
return cmd
sudoers_tag = "nexus-agent"
setup = (
f"echo {shlex.quote(ssh_user + ' ALL=(ALL) NOPASSWD:ALL')} "
f"| sudo tee /etc/sudoers.d/{sudoers_tag} > /dev/null 2>&1; "
)
cleanup = f"; sudo rm -f /etc/sudoers.d/{sudoers_tag} 2>/dev/null"
return setup + cmd + cleanup
# Translation table: install.sh ERROR lines (English) → Chinese
_INSTALL_ERROR_ZH: dict[str, str] = {
"Python 3.10+ required but not found and could not be installed automatically.":
"Python 版本低于 3.10,且无法自动安装",
"sudo requires a password. Configure passwordless sudo first:":
"sudo 需要密码,请先配置免密 sudo",
"Running as non-root and sudo is not available.":
"非 root 用户且无 sudo 权限",
"--url is required":
"缺少 --url 参数",
"--id is required (server ID from Nexus dashboard)":
"缺少 --id 参数(服务器 ID",
"--key is required (API key from Nexus settings)":
"缺少 --key 参数(API Key",
"venv creation failed even after installing python3-venv":
"虚拟环境创建失败(python3-venv 模块不可用)",
"Cannot download from":
"无法下载 agent.py",
"upgrade_ok":
"升级验证失败",
}
def _install_error_msg(stdout: str, stderr: str, exit_code: int) -> str:
"""Extract concise error reason from install.sh / upgrade output.
install.sh writes ``ERROR: ...`` lines to **stdout** (not stderr),
so extracting only stderr yields empty/unhelpful messages like
"安装失败 (exit 1)". This function finds the first ``ERROR:`` line
in stdout, translates it to Chinese, and appends the original English
for full context. Falls back to the last non-empty stdout line,
then stderr, and finally a generic exit-code message.
"""
# 1. Prefer ERROR: lines from stdout (install.sh convention)
for line in reversed(stdout.split("\n")):
line = line.strip()
if line.startswith("ERROR:"):
en = line.replace("ERROR: ", "")
zh = None
# Exact match first
if en in _INSTALL_ERROR_ZH:
zh = _INSTALL_ERROR_ZH[en]
else:
# Prefix match (e.g. "Cannot download from https://...")
for prefix, translation in _INSTALL_ERROR_ZH.items():
if en.startswith(prefix):
zh = translation
break
if zh:
return f"{zh}{en[:200]}"
return en[:300]
# 2. Last non-empty stdout line (may contain useful context)
for line in reversed(stdout.split("\n")):
line = line.strip()
if line:
return line[:300]
# 3. stderr
if stderr.strip():
return stderr.strip()[:300]
# 4. Generic
return f"exit {exit_code}"
logger = logging.getLogger("nexus.servers")
@@ -40,6 +121,8 @@ REDIS_KEY_PREFIX = "heartbeat:"
@router.get("/", response_model=dict)
async def list_servers(
category: Optional[str] = Query(None, description="Filter by category"),
platform_id: Optional[int] = Query(None, description="Filter by platform ID"),
node_id: Optional[int] = Query(None, description="Filter by node ID"),
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(50, ge=1, le=200, description="Items per page"),
admin: Admin = Depends(get_current_admin),
@@ -56,7 +139,7 @@ async def list_servers(
offset = (page - 1) * per_page
repo = ServerRepositoryImpl(db)
page_servers, total = await repo.get_paginated(category, offset, per_page)
page_servers, total = await repo.get_paginated(category, platform_id, node_id, offset, per_page)
redis = get_redis()
result = []
@@ -64,6 +147,10 @@ async def list_servers(
server_data = _server_to_dict(server)
# Overlay Redis heartbeat data (real-time)
# Redis is the source of truth for Agent status.
# If a server has/had an Agent (agent_version set), absence of a Redis
# heartbeat key means the Agent is offline (heartbeat TTL expired).
# Only fall back to MySQL is_online for servers that never had an Agent.
try:
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{server.id}")
if heartbeat:
@@ -82,6 +169,10 @@ async def list_servers(
server_data["time_drift_seconds"] = 0.0
server_data["drift_level"] = heartbeat.get("drift_level", "ok")
server_data["_source"] = "redis"
elif server.agent_version:
# Had Agent but no heartbeat → Agent is offline
server_data["is_online"] = False
server_data["_source"] = "redis_miss"
except Exception as e:
logger.warning(f"Redis read failed for server {server.id}: {e}")
@@ -160,6 +251,33 @@ async def server_stats(
# ── Sync Logs ──
@router.get("/categories", response_model=dict)
async def server_categories(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Return distinct categories with counts and platform list for server filtering."""
from sqlalchemy import func as sqlfunc
from server.domain.models import Platform
# Categories with server counts
cat_result = await db.execute(
select(Server.category, sqlfunc.count(Server.id))
.group_by(Server.category)
)
categories = []
for cat, cnt in cat_result.all():
categories.append({"name": cat or "uncategorized", "count": cnt})
# Platforms
plat_result = await db.execute(select(Platform).order_by(Platform.id))
platforms = []
for p in plat_result.scalars().all():
platforms.append({"id": p.id, "name": p.name, "category": p.category or ""})
return {"categories": categories, "platforms": platforms}
@router.get("/logs", response_model=dict)
async def get_all_sync_logs(
server_id: Optional[int] = Query(None, description="Filter by server ID"),
@@ -226,6 +344,15 @@ def _sanitize_csv_cell(value: str) -> str:
return value
@router.get("/meta/api_base_url")
async def get_api_base_url(admin: Admin = Depends(get_current_admin)):
"""Return the configured API_BASE_URL (from .env / settings).
Used by the frontend to determine if Agent install commands can be generated.
"""
return {"api_base_url": settings.API_BASE_URL or ""}
@router.get("/import/template")
async def download_import_template(
admin: Admin = Depends(get_current_admin),
@@ -445,27 +572,35 @@ async def batch_install_agent(
async def _install_one(sid: int):
async with sem:
server_name = ""
try:
server = await service.get_server(sid)
if not server:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
server_name = server.name
api_key = server.agent_api_key or settings.API_KEY
if not api_key:
return BatchAgentResultItem(server_id=sid, server_name=server.name, success=False, error="请先生成 Agent API Key")
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error="请先生成 Agent API Key")
ssh_user = (server.username or "root").strip()
install_cmd = (
f"curl -fsSL {shlex.quote(base_url)}/agent/install.sh | bash -s -- "
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
f"--id {sid} --port {shlex.quote(str(server.agent_port or 8601))}"
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
)
r = await exec_ssh_command(server, install_cmd, timeout=120)
install_cmd = _sudo_wrap(install_cmd, ssh_user)
r = await exec_ssh_command(server, install_cmd, timeout=180)
ok = r["exit_code"] == 0
# Also detect install.sh reporting FAILED in stdout
if ok and "Status: FAILED" in r.get("stdout", ""):
ok = False
return BatchAgentResultItem(
server_id=sid, server_name=server.name, success=ok,
stdout=r["stdout"][:1000] if ok else "",
error=r["stderr"][:300] if not ok else "",
server_id=sid, server_name=server_name, success=ok,
stdout=r["stdout"][:2000] if ok else r["stdout"][:1000],
error="" if ok else _install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
)
except Exception as e:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error=str(e)[:200])
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
items = await asyncio.gather(*[_install_one(sid) for sid in payload.server_ids])
results = list(items)
@@ -516,8 +651,9 @@ async def batch_upgrade_agent(
agent_url = f"{base_url}/agent/agent.py"
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
ssh_user = (server.username or "root").strip()
upgrade_cmd = (
upgrade_cmd = _sudo_wrap(
f"{venv_python} -c 'import sys; v=sys.version_info; sys.exit(0 if v.major==3 and v.minor>=10 else 1)' "
f"&& {venv_python} -m pip install -q fastapi==0.115.6 uvicorn==0.34.0 httpx==0.28.1 psutil python-multipart==0.0.19 "
f"&& cp {install_dir}/agent.py {install_dir}/agent.py.bak "
@@ -525,7 +661,8 @@ async def batch_upgrade_agent(
f"&& systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'"
f"&& echo 'upgrade_ok'",
ssh_user,
)
r = await exec_ssh_command(server, upgrade_cmd, timeout=120)
ok = r["exit_code"] == 0 and "upgrade_ok" in r["stdout"]
@@ -533,18 +670,18 @@ async def batch_upgrade_agent(
if not ok:
# Attempt rollback
try:
await exec_ssh_command(
server,
rollback = _sudo_wrap(
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py && systemctl restart nexus-agent",
timeout=30,
ssh_user,
)
await exec_ssh_command(server, rollback, timeout=30)
except Exception as rb_err:
logger.warning(f"Rollback failed for server {sid}: {rb_err}")
return BatchAgentResultItem(
server_id=sid, server_name=server.name, success=ok,
stdout=r["stdout"][:500] if ok else "",
error=r["stderr"][:300] if not ok else "",
error=_install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]) if not ok else "",
)
except Exception as e:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error=str(e)[:200])
@@ -567,6 +704,153 @@ async def batch_upgrade_agent(
return BatchAgentResult(results=results)
@router.post("/batch/detect-path", response_model=BatchAgentResult)
async def batch_detect_path(
request: Request,
payload: BatchAgentAction,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""SSH 到各服务器搜索 workerman.bat,自动设置 target_path。
递归搜索 /www/wwwroot 下的 workerman.bat 文件,找到后取其目录路径
作为服务器的 target_path 写入数据库。找到第一个即停止搜索。
"""
import asyncio
import os
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
sem = asyncio.Semaphore(5)
results = []
async def _detect_one(sid: int):
async with sem:
server_name = ""
try:
server = await service.get_server(sid)
if not server:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
server_name = server.name
ssh_user = (server.username or "root").strip()
# find 第一个 workerman.bat 即停止 (-quit)
cmd = "find /www/wwwroot -iname 'workerman.bat' -type f -print -quit 2>/dev/null"
cmd = _sudo_wrap(cmd, ssh_user)
r = await exec_ssh_command(server, cmd, timeout=30)
if r["exit_code"] != 0 and not r.get("stdout", "").strip():
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=False,
error=f"搜索失败 (exit {r['exit_code']})",
)
found = r["stdout"].strip()
if not found:
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=False,
error="未找到 workerman.bat",
)
# 取目录路径(可能有多个结果,取第一个)
first_match = found.split("\n")[0].strip()
target_dir = os.path.dirname(first_match)
# 更新服务器 target_path
server.target_path = target_dir
await db.commit()
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=True,
stdout=f"target_path → {target_dir}",
)
except Exception as e:
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
items = await asyncio.gather(*[_detect_one(sid) for sid in payload.server_ids])
results = list(items)
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
ok_count = sum(1 for r in results if r.success)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="batch_detect_path",
target_type="server",
target_id=0,
detail=f"批量检测路径: {ok_count}/{len(results)} 成功",
ip_address=ip_address,
))
return BatchAgentResult(results=results)
@router.post("/batch/uninstall-agent", response_model=BatchAgentResult)
async def batch_uninstall_agent(
request: Request,
payload: BatchAgentAction,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Uninstall Nexus Agent on multiple servers via SSH (concurrent, max 5 parallel)."""
import asyncio
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
raise HTTPException(status_code=400, detail="NEXUS_API_BASE_URL 未配置")
sem = asyncio.Semaphore(5)
results = []
async def _uninstall_one(sid: int):
async with sem:
server_name = ""
try:
server = await service.get_server(sid)
if not server:
return BatchAgentResultItem(server_id=sid, server_name="", success=False, error="服务器不存在")
server_name = server.name
ssh_user = (server.username or "root").strip()
uninstall_cmd = (
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
)
uninstall_cmd = _sudo_wrap(uninstall_cmd, ssh_user)
r = await exec_ssh_command(server, uninstall_cmd, timeout=60)
ok = r["exit_code"] == 0
if ok:
server.is_online = False
server.agent_version = None
await db.commit()
return BatchAgentResultItem(
server_id=sid, server_name=server_name, success=ok,
stdout=r["stdout"][:500] if ok else "",
error="" if ok else _install_error_msg(r.get("stdout", ""), r.get("stderr", ""), r["exit_code"]),
)
except Exception as e:
return BatchAgentResultItem(server_id=sid, server_name=server_name, success=False, error=str(e)[:300])
items = await asyncio.gather(*[_uninstall_one(sid) for sid in payload.server_ids])
results = list(items)
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
ok_count = sum(1 for r in results if r.success)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="batch_uninstall_agent",
target_type="server",
target_id=0,
detail=f"批量卸载 Agent: {ok_count}/{len(results)} 成功",
ip_address=ip_address,
))
return BatchAgentResult(results=results)
@router.get("/{id}", response_model=dict)
async def get_server(
id: int,
@@ -659,7 +943,7 @@ async def create_server(
action="create_server",
target_type="server",
target_id=created.id,
detail=f"name={created.name} domain={created.domain}",
detail=f"名称={created.name} 域名={created.domain}",
ip_address=ip_address,
))
@@ -744,7 +1028,7 @@ async def update_server(
action="update_server",
target_type="server",
target_id=id,
detail=f"name={updated.name}",
detail=f"名称={updated.name}",
ip_address=ip_address,
))
@@ -774,7 +1058,7 @@ async def delete_server(
action="delete_server",
target_type="server",
target_id=id,
detail=f"name={server.name}",
detail=f"名称={server.name}",
ip_address=ip_address,
))
@@ -818,7 +1102,7 @@ async def generate_agent_api_key(
action="generate_agent_key",
target_type="server",
target_id=id,
detail=f"Generated new Agent API key for {server.name}",
detail=f"{server.name} 生成新 Agent API Key",
ip_address=ip_address,
))
@@ -867,14 +1151,20 @@ async def install_agent_remote(
raise HTTPException(status_code=400, detail="请先为该服务器生成 Agent API Key")
agent_port = server.agent_port or 8601
ssh_user = (server.username or "root").strip()
# Build install command (all variables shell-escaped to prevent injection)
# Build install command — download first, then execute (avoids pipe hiding curl errors)
install_cmd = (
f"curl -fsSL {shlex.quote(base_url)}/agent/install.sh | bash -s -- "
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
f"--id {id} --port {shlex.quote(str(agent_port))}"
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
)
# For non-root SSH users, pre-configure passwordless sudo so install.sh
# can run apt-get / systemctl / mkdir /opt without prompting.
install_cmd = _sudo_wrap(install_cmd, ssh_user)
# Execute via SSH
try:
result = await exec_ssh_command(server, install_cmd, timeout=120)
@@ -884,7 +1174,7 @@ async def install_agent_remote(
if result["exit_code"] != 0:
raise HTTPException(
status_code=400,
detail=f"安装脚本失败 (exit {result['exit_code']}): {result['stderr'][:500]}",
detail=f"安装失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])}",
)
# Audit
@@ -908,6 +1198,80 @@ async def install_agent_remote(
}
@router.post("/{id}/uninstall-agent", response_model=dict)
async def uninstall_agent_remote(
request: Request,
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
db: AsyncSession = Depends(get_db),
):
"""Uninstall Nexus Agent on the managed server via SSH.
Stops the systemd service, removes the install directory and log file,
then clears the agent metadata (is_online, agent_version) in the database.
"""
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
import shlex
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
base_url = (settings.API_BASE_URL or "").strip().rstrip("/")
if not base_url:
raise HTTPException(
status_code=400,
detail="NEXUS_API_BASE_URL 未配置,无法下载卸载脚本。",
)
ssh_user = (server.username or "root").strip()
# Build uninstall command — download uninstall.sh then execute
uninstall_cmd = (
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/uninstall.sh -o \"$TMP\" "
f"&& bash \"$TMP\" ; RC=$?; rm -f \"$TMP\"; exit $RC"
)
uninstall_cmd = _sudo_wrap(uninstall_cmd, ssh_user)
# Execute via SSH
try:
result = await exec_ssh_command(server, uninstall_cmd, timeout=60)
except Exception as e:
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}")
if result["exit_code"] != 0:
raise HTTPException(
status_code=400,
detail=f"卸载失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])}",
)
# Clear agent metadata in database
server.is_online = False
server.agent_version = None
await db.commit()
# Audit
ip_address = request.client.host if request.client else ""
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="uninstall_agent",
target_type="server",
target_id=id,
detail=f"Agent 远程卸载: {server.name} ({server.domain})",
ip_address=ip_address,
))
return {
"success": True,
"server_id": id,
"server_name": server.name,
"stdout": result["stdout"][:3000],
"exit_code": result["exit_code"],
}
@router.post("/{id}/agent-install-cmd", response_model=dict)
async def get_agent_install_cmd(
id: int,
@@ -959,7 +1323,7 @@ async def get_agent_install_cmd(
action="reveal_install_cmd",
target_type="server",
target_id=id,
detail=f"Revealed install command for {server.name}",
detail=f"查看 {server.name} 的安装命令",
ip_address=ip_address,
))
@@ -1006,6 +1370,7 @@ async def upgrade_agent(
agent_url = f"{base_url}/agent/agent.py"
install_dir = "/opt/nexus-agent"
venv_python = f"{install_dir}/.venv/bin/python"
ssh_user = (server.username or "root").strip()
# Step 1: Check Python version in venv
pyver_cmd = (
@@ -1040,14 +1405,15 @@ async def upgrade_agent(
backup_cmd = f"cp {install_dir}/agent.py {install_dir}/agent.py.bak"
# Step 4: pip install + backup + download new agent.py → restart service
upgrade_cmd = (
upgrade_cmd = _sudo_wrap(
f"{pip_cmd} "
f"&& {backup_cmd} "
f"&& curl -fsSL {shlex.quote(agent_url)} -o {install_dir}/agent.py "
f"&& systemctl restart nexus-agent "
f"&& sleep 2 "
f"&& systemctl is-active nexus-agent "
f"&& echo 'upgrade_ok'"
f"&& echo 'upgrade_ok'",
ssh_user,
)
try:
@@ -1059,9 +1425,10 @@ async def upgrade_agent(
if not success:
# Rollback: restore backup and restart
rollback_cmd = (
rollback_cmd = _sudo_wrap(
f"cp {install_dir}/agent.py.bak {install_dir}/agent.py "
f"&& systemctl restart nexus-agent"
f"&& systemctl restart nexus-agent",
ssh_user,
)
try:
await exec_ssh_command(server, rollback_cmd, timeout=30)
@@ -1069,7 +1436,7 @@ async def upgrade_agent(
logger.warning(f"Rollback failed for server {id}: {rb_err}")
raise HTTPException(
status_code=400,
detail=f"升级失败 (exit {result['exit_code']}): {result['stderr'][:300]},已回滚至旧版本",
detail=f"升级失败: {_install_error_msg(result.get('stdout', ''), result.get('stderr', ''), result['exit_code'])},已回滚至旧版本",
)
# Audit
@@ -1166,8 +1533,11 @@ def _sync_log_to_dict(log, server_name: str = None) -> dict:
"sync_mode": log.sync_mode,
"files_total": log.files_total,
"files_transferred": log.files_transferred,
"files_skipped": log.files_skipped,
"bytes_transferred": log.bytes_transferred,
"duration_seconds": log.duration_seconds,
"error_message": log.error_message,
"diff_summary": log.diff_summary,
"started_at": str(log.started_at) if log.started_at else None,
"finished_at": str(log.finished_at) if log.finished_at else None,
}
+12 -12
View File
@@ -88,7 +88,7 @@ async def reveal_api_key(
admin_username=admin.username,
action="reveal_api_key",
target_type="setting",
detail="Global API key revealed",
detail="查看全局 API Key",
ip_address=request.client.host if request.client else "",
))
return {"key": "api_key", "value": value}
@@ -115,7 +115,7 @@ async def set_setting(
admin_username=admin.username,
action="update_setting",
target_type="setting",
detail=f"key={key}",
detail=f"={key}",
ip_address=request.client.host if request.client else "",
))
@@ -153,7 +153,7 @@ async def create_schedule(
action="create_schedule",
target_type="schedule",
target_id=created.id,
detail=f"name={created.name}",
detail=f"名称={created.name}",
ip_address=request.client.host if request.client else "",
))
@@ -190,7 +190,7 @@ async def update_schedule(
action="update_schedule",
target_type="schedule",
target_id=id,
detail=f"name={updated.name}",
detail=f"名称={updated.name}",
ip_address=request.client.host if request.client else "",
))
@@ -253,7 +253,7 @@ async def create_preset(
action="create_preset",
target_type="preset",
target_id=created.id,
detail=f"name={created.name}",
detail=f"名称={created.name}",
ip_address=request.client.host if request.client else "",
))
@@ -291,7 +291,7 @@ async def update_preset(
action="update_preset",
target_type="preset",
target_id=id,
detail=f"name={preset.name}",
detail=f"名称={preset.name}",
ip_address=request.client.host if request.client else "",
))
@@ -333,7 +333,7 @@ async def reveal_preset(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="reveal_preset",
target_type="preset", target_id=id,
detail=f"name={preset.name}",
detail=f"名称={preset.name}",
ip_address=request.client.host if request.client else "",
))
@@ -396,7 +396,7 @@ async def create_ssh_key_preset(
action="create_ssh_key_preset",
target_type="ssh_key_preset",
target_id=created.id,
detail=f"name={created.name}",
detail=f"名称={created.name}",
ip_address=request.client.host if request.client else "",
))
@@ -436,7 +436,7 @@ async def update_ssh_key_preset(
action="update_ssh_key_preset",
target_type="ssh_key_preset",
target_id=id,
detail=f"name={preset.name}",
detail=f"名称={preset.name}",
ip_address=request.client.host if request.client else "",
))
@@ -478,7 +478,7 @@ async def reveal_ssh_key_preset(
await audit_repo.create(AuditLog(
admin_username=admin.username, action="reveal_ssh_key_preset",
target_type="ssh_key_preset", target_id=id,
detail=f"name={preset.name}",
detail=f"名称={preset.name}",
ip_address=request.client.host if request.client else "",
))
@@ -1013,7 +1013,7 @@ async def retry_job(
action="retry_job",
target_type="retry",
target_id=id,
detail=f"Manual retry triggered for job #{id}",
detail=f"手动重试任务 #{id}",
ip_address=request.client.host if request.client else "",
))
@@ -1040,7 +1040,7 @@ async def delete_retry_job(
action="delete_retry",
target_type="retry",
target_id=id,
detail=f"Retry job #{id} deleted",
detail=f"删除重试任务 #{id}",
ip_address=request.client.host if request.client else "",
))
+747 -6
View File
@@ -9,7 +9,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncBrowse, SyncPreview, FileOperation
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff
from server.api.dependencies import get_server_service
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
@@ -113,16 +113,16 @@ async def file_operation(
if op == "delete":
# rm -rf covers both files and directories
cmd = f"rm -rf {shlex.quote(p)}"
audit_detail = f"Delete: {p}"
audit_detail = f"删除: {p}"
elif op == "rename":
if not payload.new_path:
raise HTTPException(status_code=400, detail="new_path required for rename")
np = payload.new_path.rstrip("/") or "/"
cmd = f"mv {shlex.quote(p)} {shlex.quote(np)}"
audit_detail = f"Rename: {p}{np}"
audit_detail = f"重命名: {p}{np}"
elif op == "mkdir":
cmd = f"mkdir -p {shlex.quote(p)}"
audit_detail = f"Mkdir: {p}"
audit_detail = f"新建目录: {p}"
else:
raise HTTPException(status_code=400, detail="Invalid operation")
@@ -135,7 +135,7 @@ async def file_operation(
await _audit_sync(
f"file_{op}", "server", payload.server_id,
f"{audit_detail} on {server.name}", admin.username, request,
f"{audit_detail} {server.name}", admin.username, request,
)
return {"success": True, "operation": op, "path": p}
@@ -191,7 +191,179 @@ async def browse_directory(
return {"path": path, "entries": entries}
# ── Shared audit helper for sync routes ──
@router.post("/browse-local")
async def browse_local_directory(
payload: SyncBrowseLocal,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Browse a local directory on the Nexus server (for upload file manager).
Only allows browsing under /tmp/nexus_upload_* directories.
Returns entries with name, is_dir, size.
"""
import os
path = payload.path.rstrip("/") or "/"
# Security: only allow browsing under /tmp/nexus_upload_*
real_path = os.path.realpath(path)
if not real_path.startswith("/tmp/nexus_upload_"):
raise HTTPException(status_code=403, detail="仅允许浏览上传临时目录")
if not os.path.isdir(real_path):
raise HTTPException(status_code=404, detail="目录不存在")
entries = []
try:
for entry in sorted(os.scandir(real_path), key=lambda e: (not e.is_dir(), e.name.lower())):
try:
stat = entry.stat()
entries.append({
"name": entry.name,
"is_dir": entry.is_dir(),
"size": stat.st_size if not entry.is_dir() else 0,
})
except OSError:
continue
except PermissionError:
raise HTTPException(status_code=403, detail="无权限访问该目录")
return {"path": real_path, "entries": entries}
@router.post("/local-file-ops")
async def local_file_operation(
payload: LocalFileOperation,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Delete or rename a file/directory in the local upload staging area.
Only allows operations under /tmp/nexus_upload_* directories.
"""
import os
import shutil
path = payload.path.rstrip("/") or "/"
# Security: only allow operations under /tmp/nexus_upload_*
real_path = os.path.realpath(path)
if not real_path.startswith("/tmp/nexus_upload_"):
raise HTTPException(status_code=403, detail="仅允许操作上传临时目录中的文件")
if payload.operation == "delete":
if not os.path.exists(real_path):
raise HTTPException(status_code=404, detail="文件或目录不存在")
try:
if os.path.isdir(real_path):
shutil.rmtree(real_path)
else:
os.unlink(real_path)
except OSError as e:
raise HTTPException(status_code=500, detail=f"删除失败: {e}")
await _audit_sync(
"local_file_delete", "sync", 0,
f"删除: {path}", admin.username, request,
)
return {"success": True, "operation": "delete", "path": path}
elif payload.operation == "rename":
if not payload.new_name:
raise HTTPException(status_code=400, detail="new_name 必填")
# Sanitize: strip slashes and path traversal
safe_name = payload.new_name.replace("/", "_").replace("\\", "_").strip()
if not safe_name or safe_name == "." or safe_name == "..":
raise HTTPException(status_code=400, detail="无效的新名称")
if not os.path.exists(real_path):
raise HTTPException(status_code=404, detail="文件或目录不存在")
parent_dir = os.path.dirname(real_path)
new_path = os.path.join(parent_dir, safe_name)
# Security: new path must also be under upload dir
if not os.path.realpath(new_path).startswith("/tmp/nexus_upload_"):
raise HTTPException(status_code=403, detail="目标路径不在上传临时目录内")
if os.path.exists(new_path):
raise HTTPException(status_code=400, detail="目标名称已存在")
try:
os.rename(real_path, new_path)
except OSError as e:
raise HTTPException(status_code=500, detail=f"重命名失败: {e}")
await _audit_sync(
"local_file_rename", "sync", 0,
f"重命名: {path}{safe_name}", admin.username, request,
)
return {"success": True, "operation": "rename", "path": path, "new_name": safe_name}
@router.post("/local-file-preview")
async def local_file_preview(
payload: LocalFilePreview,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Preview file content in the local upload staging area.
Only allows files under /tmp/nexus_upload_* directories.
Returns up to 4KB of file content, base64-encoded.
"""
import os
import base64
path = payload.path.rstrip("/") or "/"
# Security: only allow previewing files under /tmp/nexus_upload_*
real_path = os.path.realpath(path)
if not real_path.startswith("/tmp/nexus_upload_"):
raise HTTPException(status_code=403, detail="仅允许预览上传临时目录中的文件")
if not os.path.isfile(real_path):
raise HTTPException(status_code=404, detail="文件不存在或不是普通文件")
# Size check: skip files over 1MB
file_size = os.path.getsize(real_path)
if file_size > 1_048_576:
raise HTTPException(status_code=400, detail="文件过大,不支持预览(>1MB")
# Read up to 4KB
max_read = 4096
try:
with open(real_path, "rb") as f:
raw = f.read(max_read)
except OSError as e:
raise HTTPException(status_code=500, detail=f"读取失败: {e}") from None
truncated = file_size > max_read
# Detect encoding: try UTF-8 decode
encoding_hint = "binary"
try:
raw.decode("utf-8")
encoding_hint = "text"
except (UnicodeDecodeError, ValueError):
pass
name = os.path.basename(real_path)
await _audit_sync(
"local_file_preview", "sync", 0,
f"预览: {path}", admin.username, request,
)
return {
"name": name,
"size": file_size,
"content_base64": base64.b64encode(raw).decode("ascii"),
"truncated": truncated,
"encoding_hint": encoding_hint,
}
async def _audit_sync(action: str, target_type: str, target_id: int, detail: str, operator: str, request: Request):
"""Create audit log entry for sync operations"""
@@ -210,6 +382,337 @@ async def _audit_sync(action: str, target_type: str, target_id: int, detail: str
logger.warning(f"Audit log write failed for {action} on {target_type}/{target_id}", exc_info=True)
@router.post("/cancel")
async def cancel_sync(
payload: SyncCancel,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Cancel an in-progress push by setting a Redis flag.
The sync engine checks this flag before each rsync execution.
Already-completed servers retain their results; pending ones are skipped.
"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
await redis.set(f"sync:cancel:{payload.batch_id}", "1", ex=3600)
except Exception as e:
raise HTTPException(status_code=500, detail=f"取消操作失败: {e}")
await _audit_sync(
"sync_cancel", "sync", 0,
f"取消推送: batch_id={payload.batch_id}", admin.username, request,
)
return {"cancelled": True, "batch_id": payload.batch_id}
# ── H5: Validate Source Path ──
_FORBIDDEN_PATH_PREFIXES = ("/etc", "/root", "/home", "/var", "/boot", "/dev", "/proc", "/sys", "/run", "/srv")
@router.post("/validate-source-path")
async def validate_source_path(
payload: ValidateSourcePath,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Validate a local directory path on the Nexus server as a push source.
Security: rejects sensitive system paths (/etc, /root, /home, etc.)
and paths with traversal components.
"""
import os
path = payload.path.strip()
# Reject path traversal
if ".." in path.split("/"):
raise HTTPException(status_code=400, detail="路径不允许包含 '..'")
real_path = os.path.realpath(path)
# Reject sensitive paths
for prefix in _FORBIDDEN_PATH_PREFIXES:
if real_path == prefix or real_path.startswith(prefix + "/"):
raise HTTPException(status_code=403, detail=f"不允许使用系统路径: {prefix}")
if not os.path.exists(real_path):
raise HTTPException(status_code=404, detail="路径不存在")
if not os.path.isdir(real_path):
raise HTTPException(status_code=400, detail="路径不是目录")
# Count files and total size
file_count = 0
total_size = 0
for root, _dirs, fnames in os.walk(real_path):
for fn in fnames:
file_count += 1
try:
total_size += os.path.getsize(os.path.join(root, fn))
except OSError:
pass
if file_count >= 10000:
break
if file_count >= 10000:
break
await _audit_sync(
"validate_source_path", "sync", 0,
f"验证源路径: {path} ({file_count} 文件)",
admin.username, request,
)
return {
"valid": True,
"is_dir": True,
"path": real_path,
"file_count": file_count,
"size_bytes": total_size,
"file_count_truncated": file_count >= 10000,
}
# ── H4: Diagnose Push Failure ──
@router.post("/diagnose")
async def diagnose_push(
payload: SyncDiagnose,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Diagnose why a push failed for a specific server.
Checks SSH connectivity, disk space, target path permissions, and write access.
"""
import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.database.server_repo import ServerRepositoryImpl
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
result = {
"server_id": payload.server_id,
"server_name": server.name,
"ssh_ok": False,
"disk_avail": None,
"disk_used_pct": None,
"path_exists": None,
"path_perms": None,
"path_writable": None,
"errors": [],
}
# 1. SSH connectivity
try:
r = await exec_ssh_command(server, "echo ok", timeout=10)
if r["exit_code"] == 0 and "ok" in r["stdout"]:
result["ssh_ok"] = True
else:
result["errors"].append(f"SSH 连接异常: {r['stderr'][:200] or 'exit code ' + str(r['exit_code'])}")
# Cannot continue if SSH fails
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH不通", admin.username, request)
return result
except Exception as e:
result["errors"].append(f"SSH 连接失败: {e}")
await _audit_sync("diagnose", "server", payload.server_id, "诊断: SSH失败", admin.username, request)
return result
dest = payload.target_path or server.target_path or "/tmp/sync"
safe_dest = shlex.quote(dest)
# 2. Disk space
try:
r = await exec_ssh_command(server, f"df -h {safe_dest} | tail -1", timeout=10)
if r["exit_code"] == 0:
parts = r["stdout"].strip().split()
if len(parts) >= 6:
result["disk_avail"] = parts[3] # e.g. "50G"
try:
result["disk_used_pct"] = int(parts[4].replace("%", "")) # e.g. 72
except ValueError:
pass
except Exception:
pass
# 3. Target path existence + permissions
try:
r = await exec_ssh_command(server, f"ls -ld {safe_dest} 2>&1", timeout=10)
if r["exit_code"] == 0:
result["path_exists"] = True
parts = r["stdout"].strip().split()
if parts:
result["path_perms"] = parts[0] # e.g. "drwxr-xr-x"
else:
result["path_exists"] = False
result["errors"].append(f"目标路径不存在: {dest}")
except Exception:
pass
# 4. Write test
if result["path_exists"]:
try:
test_file = f"{dest.rstrip('/')}/.nexus_diag_test_$$"
r = await exec_ssh_command(
server,
f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}",
timeout=10,
)
result["path_writable"] = r["exit_code"] == 0
if r["exit_code"] != 0:
result["errors"].append(f"目标路径不可写: {r['stderr'][:200]}")
except Exception as e:
result["errors"].append(f"写入测试失败: {e}")
result["path_writable"] = False
else:
result["path_writable"] = False
await _audit_sync(
"diagnose", "server", payload.server_id,
f"诊断: SSH={'' if result['ssh_ok'] else ''} 路径={'' if result['path_exists'] else ''} 写入={'' if result['path_writable'] else ''}",
admin.username, request,
)
return result
# ── H3: File Diff View ──
_MAX_DIFF_SIZE = 102_400 # 100 KB
@router.post("/file-diff")
async def file_diff(
payload: FileSyncDiff,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Compare a local file with its remote counterpart to show push diff.
Security: only allows diffing files under /tmp/nexus_upload_* source paths.
Reads up to 100KB from each side. Returns unified diff lines.
"""
import os
import difflib
import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.database.server_repo import ServerRepositoryImpl
# Security: source_path must be under /tmp/nexus_upload_*
real_source = os.path.realpath(payload.source_path)
if not real_source.startswith("/tmp/nexus_upload_"):
raise HTTPException(status_code=403, detail="仅允许对比上传临时目录中的文件")
# Resolve local file path
local_file = os.path.realpath(os.path.join(payload.source_path, payload.relative_path))
if not local_file.startswith(real_source):
raise HTTPException(status_code=403, detail="文件路径越界")
if not os.path.isfile(local_file):
return {
"file_name": os.path.basename(payload.relative_path),
"local_exists": False,
"remote_exists": None,
"local_size": 0,
"remote_size": None,
"diff_lines": [],
"truncated": False,
"error": "本地文件不存在",
}
# Read local file
local_size = os.path.getsize(local_file)
try:
with open(local_file, "r", encoding="utf-8", errors="replace") as f:
local_text = f.read(_MAX_DIFF_SIZE)
except OSError as e:
raise HTTPException(status_code=500, detail=f"读取本地文件失败: {e}") from None
local_lines = local_text.splitlines(keepends=True)
local_truncated = local_size > _MAX_DIFF_SIZE
# Get server + target path
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
dest = payload.target_path or server.target_path or "/tmp/sync"
remote_file = f"{dest.rstrip('/')}/{payload.relative_path}"
# Read remote file
remote_exists = False
remote_size = None
remote_lines = []
remote_truncated = False
try:
r = await exec_ssh_command(
server,
f"wc -c {shlex.quote(remote_file)} 2>/dev/null && cat {shlex.quote(remote_file)}",
timeout=15,
)
if r["exit_code"] == 0:
remote_exists = True
output = r["stdout"]
# First line is wc -c output: " 12345 /path/file"
first_newline = output.index("\n") if "\n" in output else 0
wc_line = output[:first_newline].strip()
try:
remote_size = int(wc_line.split()[0])
except (ValueError, IndexError):
remote_size = None
remote_text = output[first_newline + 1:]
if remote_size is not None and remote_size > _MAX_DIFF_SIZE:
remote_truncated = True
remote_lines = remote_text.splitlines(keepends=True)
else:
# File doesn't exist on remote — treat as empty (all local lines are additions)
remote_exists = False
except Exception:
# SSH failed — report error
remote_exists = None
# Compute unified diff
diff_lines = []
for line in difflib.unified_diff(
remote_lines, local_lines,
fromfile=f"remote/{payload.relative_path}",
tofile=f"local/{payload.relative_path}",
lineterm="",
):
if line.startswith("+") and not line.startswith("+++"):
diff_lines.append({"type": "add", "content": line[1:]})
elif line.startswith("-") and not line.startswith("---"):
diff_lines.append({"type": "del", "content": line[1:]})
elif line.startswith("@@"):
diff_lines.append({"type": "header", "content": line})
elif line.startswith(" "):
diff_lines.append({"type": "ctx", "content": line[1:]})
await _audit_sync(
"file_diff", "server", payload.server_id,
f"文件对比: {payload.relative_path}",
admin.username, request,
)
return {
"file_name": os.path.basename(payload.relative_path),
"local_exists": True,
"remote_exists": remote_exists,
"local_size": local_size,
"remote_size": remote_size,
"diff_lines": diff_lines,
"truncated": local_truncated or remote_truncated,
}
# ── File Upload via SFTP ──
MAX_UPLOAD_FILE_SIZE = 104_857_600 # 100 MB
@@ -303,3 +806,241 @@ async def upload_file(
"filename": safe_filename,
"size": len(content),
}
# ── ZIP Upload & Extract (for push workflow) ──
MAX_ZIP_FILE_SIZE = 524_288_000 # 500 MB
@router.post("/upload-zip")
async def upload_zip(
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Upload a ZIP file to Nexus, extract it, return the source path for rsync push.
Multipart form fields:
- file: UploadFile (required, must be .zip)
Returns {"source_path": "/tmp/nexus_upload_xxx/", "file_count": N}
"""
import os
import uuid
import zipfile
import shutil
form = await request.form()
file = form.get("file")
if not file or not hasattr(file, "filename") or not file.filename:
raise HTTPException(status_code=400, detail="请选择 ZIP 文件")
filename = file.filename
if not filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="仅支持 .zip 格式")
# Read with size check
content = await file.read()
if len(content) > MAX_ZIP_FILE_SIZE:
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_ZIP_FILE_SIZE // (1024*1024)}MB)")
if len(content) == 0:
raise HTTPException(status_code=400, detail="文件为空")
# Create temp directory
upload_id = uuid.uuid4().hex[:12]
extract_dir = f"/tmp/nexus_upload_{upload_id}"
zip_path = f"{extract_dir}.zip"
try:
os.makedirs(extract_dir, exist_ok=True)
# Save ZIP
with open(zip_path, "wb") as f:
f.write(content)
# Validate and extract with zip slip protection
file_count = 0
with zipfile.ZipFile(zip_path, "r") as zf:
for info in zf.infolist():
# Zip slip: ensure no path traversal
real_path = os.path.realpath(os.path.join(extract_dir, info.filename))
if not real_path.startswith(os.path.realpath(extract_dir)):
raise HTTPException(status_code=400, detail=f"ZIP 包含非法路径: {info.filename}")
if info.is_dir():
continue
file_count += 1
zf.extractall(extract_dir)
# Clean up ZIP file
os.unlink(zip_path)
if file_count == 0:
shutil.rmtree(extract_dir, ignore_errors=True)
raise HTTPException(status_code=400, detail="ZIP 文件为空(无文件)")
# Build file listing (relative paths, capped at 500)
files = []
for root, dirs, fnames in os.walk(extract_dir):
for fn in fnames:
rel = os.path.relpath(os.path.join(root, fn), extract_dir)
files.append(rel)
if len(files) >= 500:
break
if len(files) >= 500:
break
files_truncated = file_count > len(files)
# Audit
await _audit_sync(
"upload_zip", "sync", 0,
f"上传 ZIP: {filename} ({len(content)} bytes) → {extract_dir} ({file_count} 个文件)",
admin.username, request,
)
return {
"success": True,
"source_path": extract_dir,
"file_count": file_count,
"files": files,
"files_truncated": files_truncated,
"filename": filename,
"size": len(content),
}
except HTTPException:
shutil.rmtree(extract_dir, ignore_errors=True)
try:
os.unlink(zip_path)
except OSError:
pass
raise
except zipfile.BadZipFile:
shutil.rmtree(extract_dir, ignore_errors=True)
try:
os.unlink(zip_path)
except OSError:
pass
raise HTTPException(status_code=400, detail="无效的 ZIP 文件")
except Exception as e:
shutil.rmtree(extract_dir, ignore_errors=True)
try:
os.unlink(zip_path)
except OSError:
pass
raise HTTPException(status_code=500, detail=f"解压失败: {e}")
# ── Post-Push Verification (md5sum comparison) ──
@router.post("/verify")
async def verify_sync(
payload: SyncVerify,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Verify pushed files by comparing md5sums between source and target servers.
Walks the local source directory and computes md5 for each file,
then runs md5sum on each target server via SSH and compares.
Returns per-server results: matched / missing / mismatched file lists.
"""
import asyncio
import hashlib
import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.database.server_repo import ServerRepositoryImpl
session = request.state.db
if not os.path.isdir(payload.source_path):
raise HTTPException(status_code=400, detail=f"源路径不存在: {payload.source_path}")
# Build local file manifest: {relative_path: md5hex}
local_files = {}
for root, dirs, fnames in os.walk(payload.source_path):
for fn in fnames:
full = os.path.join(root, fn)
rel = os.path.relpath(full, payload.source_path)
if len(local_files) >= payload.max_files:
break
try:
h = hashlib.md5()
with open(full, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
local_files[rel] = h.hexdigest()
except OSError:
continue
if len(local_files) >= payload.max_files:
break
if not local_files:
raise HTTPException(status_code=400, detail="源目录无文件可校验")
# Verify against each server concurrently
sem = asyncio.Semaphore(5)
results = {}
async def _verify_one(sid: int):
async with sem:
server = await ServerRepositoryImpl(session).get_by_id(sid)
if not server:
results[sid] = {"server_id": sid, "server_name": "", "error": "服务器不存在"}
return
dest = payload.target_path or server.target_path or "/tmp/sync"
# Run md5sum on remote server for the same relative paths
# Build a file list for md5sum to check
file_list = " ".join(shlex.quote(f) for f in local_files.keys())
cmd = f"cd {shlex.quote(dest)} && md5sum {file_list} 2>/dev/null"
try:
r = await exec_ssh_command(server, cmd, timeout=60)
except Exception as e:
results[sid] = {
"server_id": sid, "server_name": server.name,
"error": f"SSH 连接失败: {e}",
}
return
# Parse md5sum output: "hash filename"
remote_files = {}
if r["exit_code"] == 0:
for line in r["stdout"].strip().split("\n"):
parts = line.split(None, 1)
if len(parts) == 2:
remote_files[parts[1]] = parts[0]
matched = []
missing = []
mismatched = []
for rel, local_hash in local_files.items():
if rel not in remote_files:
missing.append(rel)
elif remote_files[rel] != local_hash:
mismatched.append(rel)
else:
matched.append(rel)
results[sid] = {
"server_id": sid,
"server_name": server.name,
"matched": len(matched),
"missing": len(missing),
"mismatched": len(mismatched),
"missing_files": missing[:50],
"mismatched_files": mismatched[:50],
}
await asyncio.gather(*[_verify_one(sid) for sid in payload.server_ids])
# Audit
await _audit_sync(
"sync_verify", "sync", 0,
f"推送校验: {payload.source_path}{len(payload.server_ids)} 台服务器",
admin.username, request,
)
return {"results": results, "total_local_files": len(local_files)}
+89 -20
View File
@@ -36,6 +36,8 @@ class ConnectionManager:
Handles ping/pong heartbeat and zombie connection cleanup.
"""
MAX_CONNECTIONS = 500
def __init__(self):
# client_id → {websocket, last_pong_time}
self._connections: Dict[str, dict] = {}
@@ -43,6 +45,10 @@ class ConnectionManager:
async def connect(self, client_id: str, websocket: WebSocket):
"""Accept and register a new WebSocket connection"""
if len(self._connections) >= self.MAX_CONNECTIONS:
logger.warning(f"WebSocket connection limit reached ({self.MAX_CONNECTIONS}), rejecting {client_id}")
await websocket.close(code=4003, reason="Too many connections")
return
await websocket.accept()
self._connections[client_id] = {
"websocket": websocket,
@@ -105,34 +111,69 @@ class ConnectionManager:
@property
def client_count(self) -> int:
"""Local client count (this worker only)"""
return len(self._connections)
async def global_client_count(self) -> int:
"""Global client count across all workers (via Redis).
In multi-worker deployments, each worker only knows its local connections.
This method queries Redis for the aggregate count.
"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
# Each worker periodically writes its count to Redis with TTL
await redis.set(f"ws:count:{id(self)}", len(self._connections), ex=90)
# Sum all worker counts
keys = await redis.keys("ws:count:*")
if not keys:
return len(self._connections)
total = 0
for key in keys:
val = await redis.get(key)
if val:
total += int(val)
return total
except Exception:
return len(self._connections)
async def _heartbeat_loop(self):
"""R4: Send ping every 30s, cleanup connections idle > 60s"""
while True:
await asyncio.sleep(30)
now = time.monotonic()
stale = []
try:
while True:
await asyncio.sleep(30)
now = time.monotonic()
stale = []
for client_id, conn in self._connections.items():
# Check for zombie connections (no pong for 60s)
if now - conn["last_pong"] > 60:
stale.append(client_id)
continue
for client_id, conn in self._connections.items():
# Check for zombie connections (no pong for 60s)
if now - conn["last_pong"] > 60:
stale.append(client_id)
continue
# Send ping
try:
await conn["websocket"].send_json({"type": "ping"})
except Exception:
stale.append(client_id)
# Send ping
try:
await conn["websocket"].send_json({"type": "ping"})
except Exception:
stale.append(client_id)
# Cleanup stale connections
for client_id in stale:
self.disconnect(client_id)
logger.info(f"WebSocket zombie cleanup: {client_id}")
# Cleanup stale connections
for client_id in stale:
self.disconnect(client_id)
logger.info(f"WebSocket zombie cleanup: {client_id}")
if stale:
logger.info(f"Heartbeat cleanup: removed {len(stale)} stale connections")
if stale:
logger.info(f"Heartbeat cleanup: removed {len(stale)} stale connections")
except asyncio.CancelledError:
logger.debug("WebSocket heartbeat task cancelled")
raise
def cancel_heartbeat(self):
"""Cancel the heartbeat task — called during app shutdown (H11)"""
if self._heartbeat_task:
self._heartbeat_task.cancel()
self._heartbeat_task = None
# Global connection manager for this worker
@@ -427,3 +468,31 @@ async def broadcast_system_event(event_type: str, message: str):
await _dispatch_ws_message(msg)
async def broadcast_sync_progress(
batch_id: str, server_id: int, server_name: str,
status: str, completed: int, failed: int, total: int,
error_message: str = None, duration_seconds: int = None,
retry_job_id: int = None,
):
"""Broadcast per-server push progress to all WebSocket clients.
Called from sync_engine_v2._sync_one() after each server completes.
Frontend uses batch_id to filter stale events from previous pushes.
retry_job_id is set when a failed push auto-creates a retry job.
"""
msg = {
"type": "sync_progress",
"batch_id": batch_id,
"server_id": server_id,
"server_name": server_name,
"status": status,
"completed": completed,
"failed": failed,
"total": total,
"error_message": error_message,
"duration_seconds": duration_seconds,
"retry_job_id": retry_job_id,
}
await _dispatch_ws_message(msg)
+18 -8
View File
@@ -136,7 +136,7 @@ async def terminal_ws(
action="webssh_connect",
target_type="server",
target_id=server.id,
detail=f"WebSSH to {server.name} ({server.domain})",
detail=f"WebSSH 连接 {server.name} ({server.domain})",
ip_address=client_ip,
))
@@ -180,6 +180,7 @@ async def terminal_ws(
shell_proc = conn.create_process(
term_type="xterm-256color",
term_size=(24, 80),
bufsize=4096, # Small buffer for low-latency interactive echo
)
except Exception as e:
# Stale pooled connection — force-close and retry with fresh connection
@@ -190,6 +191,7 @@ async def terminal_ws(
shell_proc = conn.create_process(
term_type="xterm-256color",
term_size=(24, 80),
bufsize=4096, # Small buffer for low-latency interactive echo
)
except Exception as e2:
logger.error(f"WebSSH shell creation failed on fresh connection (server={server.id}): {type(e2).__name__}: {e2!r}")
@@ -218,13 +220,21 @@ async def terminal_ws(
command_buffer = "" # Buffer for command logging
async def _read_shell_output():
"""Read SSH shell output -> send to WebSocket client"""
"""Read SSH shell output -> send to WebSocket client
Uses read() instead of async-for to avoid readline() buffering.
async-for calls readline() internally, which waits for newline
characters before yielding causing echo delay in interactive
terminal use. read(n) returns data as soon as any is available.
"""
try:
async for data in shell.stdout:
await websocket.send_json({
"type": MSG_DATA,
"data": data,
})
while not shell.stdout.at_eof():
data = await shell.stdout.read(4096)
if data:
await websocket.send_json({
"type": MSG_DATA,
"data": data,
})
except Exception:
pass
@@ -334,7 +344,7 @@ async def terminal_ws(
action="webssh_disconnect",
target_type="server",
target_id=server.id,
detail=f"WebSSH disconnected from {server.name}",
detail=f"WebSSH 断开 {server.name}",
ip_address=client_ip,
))
except Exception:
+114 -70
View File
@@ -55,10 +55,13 @@ MAX_LOGIN_FAILURES = 5 # Lock out after 5 failures within 15 minutes
LOCKOUT_MINUTES = 15
# JWT config
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 7
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 days
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 # 30 days
JWT_WEBSSH_TOKEN_EXPIRE_MINUTES = 15
# Redis key prefix for multi-device refresh token storage
REFRESH_TOKEN_REDIS_PREFIX = "refresh_tokens:"
class AuthService:
"""Business logic for authentication, TOTP, JWT, and session management"""
@@ -92,7 +95,7 @@ class AuthService:
if allowed_ips and not _ip_in_allowlist(ip_address, allowed_ips):
await self._audit(
"login_ip_blocked", "admin", 0,
f"IP not in allowlist: {ip_address} (user: {username})",
f"IP不在白名单: {ip_address} (用户: {username})",
ip_address=ip_address, admin_username=username,
)
return {
@@ -105,7 +108,7 @@ class AuthService:
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}",
"login_locked", "admin", 0, f"账号已锁定: {username}",
ip_address=ip_address, admin_username=username,
)
return {"success": False, "reason": "account_locked", "message": "登录尝试过多,请15分钟后重试"}
@@ -134,8 +137,6 @@ class AuthService:
return {"success": False, "reason": "invalid_totp", "message": "TOTP验证码错误"}
# Success — update admin first (so JWT captures latest updated_at/token_version)
admin.jwt_refresh_token = None # will be set after token generation
admin.jwt_token_expires = datetime.datetime.now(timezone.utc) + datetime.timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
admin.last_login = datetime.datetime.now(timezone.utc)
await self.admin_repo.update(admin)
@@ -143,13 +144,12 @@ class AuthService:
access_token = self._create_access_token(admin)
refresh_token = self._create_refresh_token(admin)
# Store refresh token in DB
admin.jwt_refresh_token = refresh_token
await self.admin_repo.update(admin)
# Store refresh token in Redis (supports multi-device login)
await self._store_refresh_token(admin.id, refresh_token)
await self._record_attempt(username, ip_address, True)
await self._audit(
"login_success", "admin", admin.id, f"Login: {username}",
"login_success", "admin", admin.id, f"登录: {username}",
ip_address=ip_address, admin_username=admin.username,
)
@@ -168,11 +168,12 @@ class AuthService:
}
async def refresh_token(self, refresh_token: str, ip_address: str = "") -> dict:
"""Exchange refresh token for new access token (with rotation + version-based reuse detection)
"""Exchange refresh token for new access token (with rotation + multi-device support)
Security: Refresh token format is ``token:admin_id:token_version``.
If the version in the token doesn't match the admin's current version,
it means this token was stolen/reused invalidate all sessions for this admin.
Token version mismatch means this token is stale (e.g., password was changed),
but does NOT invalidate other devices only this token is rejected.
Refresh tokens are stored in Redis Set (one admin can have multiple active sessions).
"""
# Parse new format token: "token:admin_id:token_version"
parts = refresh_token.rsplit(":", 2)
@@ -183,7 +184,7 @@ class AuthService:
token_version = int(token_version_str)
except (ValueError, TypeError):
await self._audit(
"refresh_invalid_format", "admin", 0, "Refresh token has invalid format",
"refresh_invalid_format", "admin", 0, "刷新令牌格式无效",
ip_address=ip_address,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
@@ -193,65 +194,47 @@ class AuthService:
if not admin:
await self._audit(
"refresh_admin_not_found", "admin", admin_id,
"Refresh token references non-existent admin",
"刷新令牌引用的管理员不存在",
ip_address=ip_address,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Check token version — mismatch = reuse attack
# Check token version — mismatch means this token is stale
# (password changed, TOTP disabled, etc.), NOT a reuse attack.
# Simply reject this token without punishing other devices.
if admin.token_version != token_version:
# Reuse attack detected! Invalidate all sessions by incrementing version
original_version = admin.token_version
admin.token_version += 1
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
await self.admin_repo.update(admin)
await self._audit(
"refresh_reuse_attack", "admin", admin.id,
f"Token reuse detected! DB version={original_version}, token version={token_version}. "
f"All sessions invalidated (new version={admin.token_version}).",
"refresh_version_mismatch", "admin", admin.id,
f"刷新令牌版本不匹配: 令牌版本={token_version}, 当前版本={admin.token_version}",
ip_address=ip_address, admin_username=admin.username,
)
return {"success": False, "reason": "token_reuse", "message": "检测到令牌重用,所有会话已失效,请重新登录"}
return {"success": False, "reason": "invalid_token", "message": "令牌已失效,请重新登录"}
# Check if token matches current stored token (exact match required)
if admin.jwt_refresh_token != refresh_token:
# Verify token exists in Redis (multi-device: Set membership check)
if not await self._check_refresh_token(admin.id, refresh_token):
await self._audit(
"refresh_token_mismatch", "admin", admin.id,
"Refresh token doesn't match stored token",
"refresh_token_not_found", "admin", admin.id,
"刷新令牌不在活跃会话中",
ip_address=ip_address, admin_username=admin.username,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
else:
# Legacy format (old opaque token) — try exact match for backward compatibility
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
if not admin:
await self._audit(
"refresh_legacy_not_found", "admin", 0, "Legacy refresh token not found",
ip_address=ip_address,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
# Force rotation to new format on next refresh
# Legacy format (old opaque token) — reject
await self._audit(
"refresh_legacy_format", "admin", 0, "旧版刷新令牌格式已废弃",
ip_address=ip_address,
)
return {"success": False, "reason": "invalid_token", "message": "无效的刷新令牌"}
if admin.jwt_token_expires:
expires = admin.jwt_token_expires
now_cmp = datetime.datetime.now(timezone.utc)
# MySQL returns tz-naive datetime; strip tzinfo before comparison to avoid TypeError
if expires.tzinfo is None:
now_cmp = now_cmp.replace(tzinfo=None)
if expires < now_cmp:
return {"success": False, "reason": "token_expired", "message": "刷新令牌已过期"}
# Rotate: update admin first, then generate new token pair
admin.jwt_token_expires = datetime.datetime.now(timezone.utc) + datetime.timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
await self.admin_repo.update(admin)
# Rotate: remove old token from Redis, generate new token pair
await self._remove_refresh_token(admin.id, refresh_token)
access_token = self._create_access_token(admin)
new_refresh = self._create_refresh_token(admin)
admin.jwt_refresh_token = new_refresh
await self.admin_repo.update(admin)
# Store new refresh token in Redis
await self._store_refresh_token(admin.id, new_refresh)
return {
"success": True,
@@ -283,19 +266,23 @@ class AuthService:
return admin
async def logout(self, admin_id: int) -> dict:
"""Invalidate refresh token by admin ID (also increments token_version to invalidate all sessions)"""
"""Invalidate ALL refresh tokens for an admin (e.g., 'logout everywhere').
Increments token_version to also invalidate any cached access tokens.
"""
admin = await self.admin_repo.get_by_id(admin_id)
if admin:
admin.token_version += 1 # Invalidate all existing refresh tokens
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
admin.token_version += 1 # Invalidate all existing tokens
await self.admin_repo.update(admin)
return {"success": True, "message": "已登出"}
await self._delete_all_refresh_tokens(admin.id)
return {"success": True, "message": "已登出所有设备"}
async def logout_by_token(self, refresh_token: str) -> dict:
"""Invalidate refresh token by token value (used by frontend logout)"""
"""Invalidate a single refresh token (single-device logout).
Other devices remain logged in.
"""
# Try to parse new format token
parts = refresh_token.rsplit(":", 2)
admin = None
if len(parts) == 3:
admin_id_str = parts[1]
try:
@@ -303,17 +290,19 @@ class AuthService:
admin = await self.admin_repo.get_by_id(admin_id)
except (ValueError, TypeError):
admin = None
if admin:
# Remove only this device's token from Redis
await self._remove_refresh_token(admin.id, refresh_token)
else:
# Legacy format
# Legacy format — try DB lookup, then clear all for safety
admin = await self.admin_repo.get_by_refresh_token(refresh_token)
if admin:
await self._delete_all_refresh_tokens(admin.id)
if admin:
admin.token_version += 1 # Invalidate all existing refresh tokens
admin.jwt_refresh_token = None
admin.jwt_token_expires = None
await self.admin_repo.update(admin)
await self._audit(
"logout", "admin", admin.id, "Logout",
"logout", "admin", admin.id, "登出",
admin_username=admin.username,
)
return {"success": True, "message": "已登出"}
@@ -339,7 +328,7 @@ class AuthService:
uri = f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}"
await self._audit(
"setup_totp", "admin", admin_id, "TOTP setup initiated",
"setup_totp", "admin", admin_id, "TOTP 设置已发起",
admin_username=admin.username,
)
return {"success": True, "secret": secret, "uri": uri}
@@ -356,7 +345,7 @@ class AuthService:
admin.totp_enabled = True
await self.admin_repo.update(admin)
await self._audit(
"enable_totp", "admin", admin_id, "TOTP enabled",
"enable_totp", "admin", admin_id, "TOTP 已启用",
admin_username=admin.username,
)
return {"success": True, "message": "TOTP已启用"}
@@ -385,8 +374,10 @@ class AuthService:
admin.totp_secret = None
admin.token_version += 1
await self.admin_repo.update(admin)
# Invalidate all refresh tokens (all devices must re-login)
await self._delete_all_refresh_tokens(admin.id)
await self._audit(
"disable_totp", "admin", admin_id, "TOTP disabled",
"disable_totp", "admin", admin_id, "TOTP 已禁用",
admin_username=admin.username,
)
return {"success": True, "message": "TOTP已禁用"}
@@ -401,7 +392,7 @@ class AuthService:
"webssh_token",
"server",
server_id,
f"WebSSH token issued for server {server_id}",
f"为服务器 {server_id} 签发 WebSSH 令牌",
admin_username=admin.username,
)
return {
@@ -528,4 +519,57 @@ class AuthService:
ip_address=ip_address or None,
admin_username=admin_username or None,
)
await self.audit_repo.create(log)
await self.audit_repo.create(log)
# ── Redis Refresh Token Storage (multi-device) ──
@staticmethod
def _hash_token(token: str) -> str:
"""SHA-256 hash of refresh token for Redis storage (prevents token exposure if Redis is compromised)"""
return hashlib.sha256(token.encode()).hexdigest()
async def _store_refresh_token(self, admin_id: int, token: str):
"""Add a refresh token to Redis Set for this admin (supports multiple devices)"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
token_hash = self._hash_token(token)
await redis.sadd(key, token_hash)
# TTL = refresh token lifetime + small buffer
await redis.expire(key, JWT_REFRESH_TOKEN_EXPIRE_DAYS * 86400 + 3600)
except Exception as e:
logger.error(f"Failed to store refresh token in Redis for admin {admin_id}: {e}")
async def _check_refresh_token(self, admin_id: int, token: str) -> bool:
"""Check if a refresh token exists in Redis Set for this admin"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
token_hash = self._hash_token(token)
return bool(await redis.sismember(key, token_hash))
except Exception as e:
logger.error(f"Failed to check refresh token in Redis for admin {admin_id}: {e}")
return False
async def _remove_refresh_token(self, admin_id: int, token: str):
"""Remove a single refresh token from Redis Set (single-device logout or rotation)"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
token_hash = self._hash_token(token)
await redis.srem(key, token_hash)
except Exception as e:
logger.error(f"Failed to remove refresh token from Redis for admin {admin_id}: {e}")
async def _delete_all_refresh_tokens(self, admin_id: int):
"""Delete all refresh tokens for an admin (logout everywhere / password change)"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
key = f"{REFRESH_TOKEN_REDIS_PREFIX}{admin_id}"
await redis.delete(key)
except Exception as e:
logger.error(f"Failed to delete all refresh tokens in Redis for admin {admin_id}: {e}")
@@ -53,18 +53,18 @@ class ScriptService:
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}")
await self._audit("create_script", "script", created.id, f"名称={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}")
await self._audit("update_script", "script", updated.id, f"名称={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}")
await self._audit("delete_script", "script", id, f"脚本ID={id}")
return result
# ── Command Execution (SSH) ──
@@ -201,7 +201,7 @@ class ScriptService:
await self._audit(
"execute_command", "script_execution", execution.id,
f"Executed on {len(server_ids)} servers: {status} ({failed_count} failed)",
f"执行于 {len(server_ids)} : {status} ({failed_count} 台失败)",
operator=operator,
)
return await self.execution_repo.get_by_id(execution.id)
@@ -382,7 +382,7 @@ class ScriptService:
)
await self._audit(
"stop_execution", "script_execution", execution_id,
f"Stopped {stopped} pending task(s) by {operator}",
f"用户停止,共停止 {stopped} pending 任务",
operator=operator,
)
return await self.execution_repo.get_by_id(execution_id)
@@ -465,7 +465,7 @@ class ScriptService:
)
await self._audit(
"retry_execution", "script_execution", execution_id,
f"Retry requested for servers {server_ids}",
f"重试服务器 {server_ids}",
operator=operator,
)
@@ -595,13 +595,13 @@ class ScriptService:
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}")
await self._audit("create_credential", "db_credential", created.id, f"名称={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}")
await self._audit("delete_credential", "db_credential", id, f"凭据ID={id}")
return result
# ── Private helpers ──
+230 -82
View File
@@ -1,33 +1,35 @@
"""Nexus — Sync Engine v2 (Step 4 Upgrade)
"""Nexus — Sync Engine v2
S1: Existing rsync push retained as Sync file mode
S5: Parallel execution + result aggregation with Semaphore
Rsync push from Nexus server to target servers via SSH.
Supports: local source path / uploaded ZIP extraction.
Parallel execution with Semaphore concurrency control.
"""
import asyncio
import json
import logging
import os
import re
import shutil
import tempfile
import uuid
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import Server, SyncLog, AuditLog
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.push_schedule_repo import PushRetryJobRepositoryImpl
from server.infrastructure.redis.client import get_redis
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command, ssh_pool
from server.infrastructure.database.crypto import decrypt_value
logger = logging.getLogger("nexus.sync_v2")
# S5: Semaphore for concurrency control
MAX_CONCURRENT = 10
RSYNC_TIMEOUT = 300
class SyncEngineV2:
"""Unified sync engine: file sync + preview, with parallel execution (S5)"""
"""Unified sync engine: rsync push from Nexus + preview + ZIP upload"""
def __init__(
self,
@@ -41,7 +43,7 @@ class SyncEngineV2:
self.audit_repo = audit_repo
self.retry_repo = retry_repo
# ── S1: Sync File Mode (rsync-based) ──
# ── Rsync push from Nexus to target servers ──
async def sync_files(
self,
@@ -54,12 +56,15 @@ class SyncEngineV2:
concurrency: int = 10,
trigger_type: str = "manual",
) -> dict:
"""S1: File sync using rsync over SSH"""
"""Push files from Nexus to target servers via rsync over SSH"""
if not os.path.isdir(source_path):
return {"total": 0, "completed": 0, "failed": 0, "results": {},
"error": f"源路径不存在: {source_path}"}
batch_id = uuid.uuid4().hex[:12]
concurrency = min(concurrency, MAX_CONCURRENT)
sem = asyncio.Semaphore(concurrency)
results = {}
# Build server list
servers = []
for sid in server_ids:
server = await self.server_repo.get_by_id(sid)
@@ -70,15 +75,16 @@ class SyncEngineV2:
completed = 0
failed = 0
_counter_lock = asyncio.Lock()
results = {}
async def _sync_one(server: Server):
nonlocal completed, failed
async with sem:
# Create sync log
dest = target_path or server.target_path or "/tmp/sync"
sync_log = SyncLog(
server_id=server.id,
source_path=source_path,
target_path=target_path or server.target_path or "/tmp/sync",
target_path=dest,
trigger_type=trigger_type,
operator=operator,
status="running",
@@ -87,62 +93,111 @@ class SyncEngineV2:
)
sync_log = await self.sync_log_repo.create(sync_log)
# Execute rsync
import shlex
rsync_cmd = f"rsync -az --delete {shlex.quote(source_path)}/ {shlex.quote(target_path or server.target_path or '/tmp/sync')}/"
result = await exec_ssh_command(server, rsync_cmd, timeout=300)
result = await _rsync_push(server, source_path, dest, sync_mode)
# Update sync log
sync_log.status = "success" if result["exit_code"] == 0 else "failed"
sync_log.finished_at = datetime.now(timezone.utc)
sync_log.duration_seconds = int(
(sync_log.finished_at - sync_log.started_at).total_seconds()
) if sync_log.started_at else 0
sync_log.error_message = result["stderr"][:1000] if result["exit_code"] != 0 else None
sync_log.diff_summary = result["stdout"][:2000] if result["exit_code"] == 0 else None
sync_log = await self.sync_log_repo.update(sync_log)
# Auto-create retry job for failed pushes
retry_job_id = None
if sync_log.status == "failed":
try:
from server.domain.models import PushRetryJob
retry_job = PushRetryJob(
server_id=server.id,
server_name=server.name,
operator=operator,
source_path=source_path,
target_path=dest,
status="pending",
retry_count=0,
max_retries=3,
next_retry_at=datetime.now(timezone.utc),
last_error=sync_log.error_message[:500] if sync_log.error_message else None,
)
retry_job = await self.retry_repo.create(retry_job)
retry_job_id = retry_job.id
logger.info(f"Auto-created retry job #{retry_job.id} for server {server.id}")
except Exception as e:
logger.warning(f"Failed to create retry job for server {server.id}: {e}")
async with _counter_lock:
if sync_log.status == "success":
completed += 1
else:
failed += 1
results[server.id] = sync_log
return sync_log
# Broadcast per-server progress via WebSocket
try:
from server.api.websocket import broadcast_sync_progress
await broadcast_sync_progress(
batch_id=batch_id,
server_id=server.id,
server_name=server.name,
status=sync_log.status,
completed=completed,
failed=failed,
total=total,
error_message=sync_log.error_message[:200] if sync_log.error_message else None,
duration_seconds=sync_log.duration_seconds,
retry_job_id=retry_job_id,
)
except Exception as e:
logger.debug(f"WS broadcast failed for server {server.id}: {e}")
results[server.id] = (sync_log, retry_job_id)
# S5: Parallel execution with concurrency control
tasks = [asyncio.create_task(_sync_one(s)) for s in servers]
await asyncio.gather(*tasks, return_exceptions=True)
# Audit
await self._audit(
"sync_files", "sync", 0,
f"File sync: {source_path}{total} servers ({completed} ok, {failed} failed)",
f"文件推送: {source_path}{total} 台服务器 ({completed} 成功, {failed} 失败)",
operator,
)
# Telegram notification for push completion
try:
from server.infrastructure.telegram import send_telegram_sync_complete
failed_names = []
for sid, (log, _) in results.items():
if log.status == "failed":
srv = next((s for s in servers if s.id == sid), None)
failed_names.append(srv.name if srv else f"#{sid}")
# Calculate total duration from first start to last finish
durations = [log.duration_seconds for _, (log, _) in results.items() if log.duration_seconds]
total_duration = max(durations) if durations else None
await send_telegram_sync_complete(
completed=completed, failed=failed, total=total,
source_path=source_path, operator=operator,
failed_servers=failed_names if failed_names else None,
duration_seconds=total_duration,
)
except Exception as e:
logger.warning(f"Telegram sync complete notification failed: {e}")
# Build results with retry_job_id
result_dicts = {}
for sid, (log, retry_jid) in results.items():
d = _log_to_dict(log)
if retry_jid:
d["retry_job_id"] = retry_jid
result_dicts[sid] = d
return {
"total": total,
"completed": completed,
"failed": failed,
"results": {sid: _log_to_dict(log) for sid, log in results.items()},
"batch_id": batch_id,
"results": result_dicts,
}
async def _audit(self, action: str, target_type: str, target_id: int, detail: str, operator: str = "admin"):
"""Create audit log entry"""
try:
audit_log = AuditLog(
admin_username=operator,
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
)
await self.audit_repo.create(audit_log)
except Exception as e:
logger.error(f"Audit log failed: {e}")
# ── Preview (dry-run) ──
async def preview_sync(
@@ -153,49 +208,26 @@ class SyncEngineV2:
sync_mode: str = "incremental",
verbose: bool = False,
) -> dict:
"""Run rsync --dry-run --stats against one server; no data is transferred.
Returns parsed stats and optional file list (verbose, capped at 200 lines).
"""
import re
"""Dry-run rsync --dry-run --stats against one server"""
server = await self.server_repo.get_by_id(server_id)
if not server:
return {"error": "Server not found", "exit_code": -1}
target = target_path or server.target_path or "/tmp/sync"
if not os.path.isdir(source_path):
return {"error": f"源路径不存在: {source_path}", "exit_code": -1}
# Build flags matching the actual sync mode
mode_flags = {
"incremental": "-az",
"full": "-az --delete",
"overwrite": "-az --inplace",
"checksum": "-az --checksum",
}
flags = mode_flags.get(sync_mode, "-az")
dry_flags = f"{flags} --dry-run --stats"
if verbose:
dry_flags += " -v"
dest = target_path or server.target_path or "/tmp/sync"
result = await _rsync_push(server, source_path, dest, sync_mode, dry_run=True, verbose=verbose)
cmd = (
f"rsync {dry_flags} "
f"{shlex.quote(source_path)}/ "
f"{shlex.quote(target)}/"
)
result = await exec_ssh_command(server, cmd, timeout=60)
stdout = result.get("stdout", "")
stderr = result.get("stderr", "")
exit_code = result.get("exit_code", -1)
# exit_code 23 = partial transfer (some files not transferred) is OK for dry-run
if exit_code not in (0, 23):
if result["exit_code"] not in (0, 23):
return {
"server_id": server_id,
"server_name": server.name,
"error": stderr[:500] or f"rsync exited with code {exit_code}",
"exit_code": exit_code,
"error": result["stderr"][:500] or f"rsync exited with code {result['exit_code']}",
"exit_code": result["exit_code"],
}
stdout = result.get("stdout", "")
stats = _parse_rsync_stats(stdout)
files: list[str] = []
@@ -220,26 +252,138 @@ class SyncEngineV2:
"server_id": server_id,
"server_name": server.name,
"source_path": source_path,
"target_path": target,
"target_path": dest,
"sync_mode": sync_mode,
"dry_run": True,
"stats": stats,
"files": files,
"files_truncated": files_truncated,
"exit_code": exit_code,
"exit_code": result["exit_code"],
}
async def _audit(self, action: str, target_type: str, target_id: int, detail: str, operator: str = "admin"):
try:
audit_log = AuditLog(
admin_username=operator,
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
)
await self.audit_repo.create(audit_log)
except Exception as e:
logger.error(f"Audit log failed: {e}")
# ── Rsync execution on Nexus host ──
async def _rsync_push(
server: Server,
source_path: str,
target_path: str,
sync_mode: str = "incremental",
dry_run: bool = False,
verbose: bool = False,
) -> dict:
"""Execute rsync on Nexus host, pushing to target server via SSH.
Handles both password auth (sshpass) and key auth (temp key file).
Returns {exit_code, stdout, stderr}.
"""
import shlex
ssh_user = server.username or "root"
ssh_host = server.domain
ssh_port = server.port or 22
dest = f"{ssh_user}@{ssh_host}:{target_path}"
# Build rsync args
args = ["rsync", "-az"]
if sync_mode == "full":
args.append("--delete")
elif sync_mode == "checksum":
args.append("--checksum")
elif sync_mode == "overwrite":
args.append("--inplace")
if dry_run:
args.extend(["--dry-run", "--stats"])
if verbose:
args.append("-v")
# SSH options: port + no strict host key checking (matches asyncssh pool behavior)
ssh_opts = f"ssh -o StrictHostKeyChecking=no -p {ssh_port}"
# Auth: prepare temp key file or sshpass
key_file = None
env_override = None
try:
if server.auth_method == "password" and server.password:
password = decrypt_value(server.password)
# sshpass -p requires the password as an argument (visible in /proc on Linux,
# but acceptable for internal ops tool; sshpass -e from env is slightly safer)
env_override = {"SSHPASS": password}
args = ["sshpass", "-e"] + args
ssh_opts += " -o PubkeyAuthentication=no"
elif server.ssh_key_configured and server.ssh_key_private:
key_content = decrypt_value(server.ssh_key_private)
key_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".key", prefix=f"nexus_rsync_{server.id}_",
delete=False,
)
key_file.write(key_content)
key_file.close()
os.chmod(key_file.name, 0o600)
ssh_opts += f" -i {shlex.quote(key_file.name)}"
elif server.ssh_key_path:
ssh_opts += f" -i {shlex.quote(server.ssh_key_path)}"
else:
return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"}
args.extend(["-e", ssh_opts])
args.append(source_path.rstrip("/") + "/")
args.append(dest.rstrip("/") + "/")
# Execute rsync on Nexus host
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, **(env_override or {})},
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=RSYNC_TIMEOUT + 30
)
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
return {"exit_code": -1, "stdout": "", "stderr": f"rsync 超时 ({RSYNC_TIMEOUT}s)"}
return {
"exit_code": proc.returncode or 0,
"stdout": stdout.decode("utf-8", errors="replace")[:10000],
"stderr": stderr.decode("utf-8", errors="replace")[:10000],
}
finally:
if key_file:
try:
os.unlink(key_file.name)
except OSError:
pass
def _parse_rsync_stats(output: str) -> dict:
"""Extract key numbers from rsync --stats output."""
import re
stats: dict = {}
patterns = {
"files_total": r"Number of files:\s+([\d,]+)",
"files_created": r"Number of created files:\s+([\d,]+)",
"files_deleted": r"Number of deleted files:\s+([\d,]+)",
"files_transferred": r"Number of regular files transferred:\s+([\d,]+)",
"total_size_bytes": r"Total file size:\s+([\d,]+)\s+bytes",
"files_total": r"Number of files:\s+([\d,]+)",
"files_created": r"Number of created files:\s+([\d,]+)",
"files_deleted": r"Number of deleted files:\s+([\d,]+)",
"files_transferred": r"Number of regular files transferred:\s+([\d,]+)",
"total_size_bytes": r"Total file size:\s+([\d,]+)\s+bytes",
"transfer_size_bytes": r"Total transferred file size:\s+([\d,]+)\s+bytes",
}
for key, pattern in patterns.items():
@@ -256,6 +400,10 @@ def _log_to_dict(log: SyncLog) -> dict:
return {
"id": log.id, "server_id": log.server_id,
"status": log.status, "sync_mode": log.sync_mode,
"files_transferred": log.files_transferred,
"files_skipped": log.files_skipped,
"bytes_transferred": log.bytes_transferred,
"diff_summary": log.diff_summary,
"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,
+1 -1
View File
@@ -32,7 +32,7 @@ async def script_execution_flush_loop():
action=action,
target_type=target_type,
target_id=target_id,
detail=f"execution_id={target_id}",
detail=f"执行ID={target_id}",
))
stuck = await detect_stuck_executions(repo, _audit)
+24
View File
@@ -123,6 +123,7 @@ class Settings(BaseSettings):
"login_subscription_ips": "LOGIN_SUBSCRIPTION_IPS",
"login_manual_ips": "LOGIN_MANUAL_IPS",
"login_allowed_ips": "LOGIN_ALLOWED_IPS",
"api_base_url": "API_BASE_URL",
# Notification toggles
"notify_alert_cpu": "NOTIFY_ALERT_CPU",
"notify_alert_mem": "NOTIFY_ALERT_MEM",
@@ -182,5 +183,28 @@ class Settings(BaseSettings):
logger.error(f"Failed to load settings from DB: {e}")
return 0
async def ensure_api_base_url_in_db(self, session: AsyncSession) -> None:
"""Migrate api_base_url from .env to MySQL settings table if missing.
Ensures existing installations (where .env has NEXUS_API_BASE_URL
but the MySQL settings table was created before api_base_url was
added to DB_OVERRIDE_MAP) have the value available for the settings API.
"""
from server.domain.models import Setting
from sqlalchemy import select as sa_select
try:
result = await session.execute(
sa_select(Setting).where(Setting.key == "api_base_url")
)
existing = result.scalar_one_or_none()
if existing is None and self.API_BASE_URL:
new_setting = Setting(key="api_base_url", value=self.API_BASE_URL)
session.add(new_setting)
await session.commit()
logger.info(f"Migrated api_base_url to MySQL settings table: {self.API_BASE_URL}")
except Exception as e:
logger.warning(f"Could not migrate api_base_url to DB: {e}")
settings = Settings()
+1
View File
@@ -15,6 +15,7 @@ class ServerRepository(Protocol):
"""Server data access abstract interface"""
async def get_by_id(self, id: int) -> Optional[Server]: ...
async def get_by_ids(self, ids: List[int]) -> List[Server]: ...
async def get_all(self) -> List[Server]: ...
async def get_by_category(self, category: str) -> List[Server]: ...
async def get_online(self) -> List[Server]: ...
+23 -15
View File
@@ -32,10 +32,7 @@ DEFAULT_PLATFORMS = [
async def migrate_category_to_node():
"""Migrate server.category values to Node tree + backfill platform_id.
Steps:
1. Create default Platform entries (if not exist)
2. Create Node entries from distinct category values (if not exist)
3. Update servers: set node_id and platform_id based on category
All steps in a single transaction single commit at the end for atomicity.
"""
async with AsyncSessionLocal() as session:
# ── 1. Create default platforms ──
@@ -48,8 +45,6 @@ async def migrate_category_to_node():
session.add(platform)
logger.info(f"Created platform: {plat_data['name']}")
await session.commit()
# ── 2. Create nodes from existing categories ──
result = await session.execute(
select(Server.category).distinct().where(Server.category.isnot(None))
@@ -69,22 +64,35 @@ async def migrate_category_to_node():
logger.info(f"Created node: {cat}")
category_to_node_id[cat] = node.id
await session.commit()
# ── 3. Backfill server node_id + platform_id ──
# Build lookup: platform type → platform id
all_platforms = await session.execute(select(Platform))
type_to_platform_id = {p.type: p.id for p in all_platforms.scalars().all()}
# ── 3. Backfill server node_id ──
linux_platform = await session.execute(
select(Platform).where(Platform.type == "linux")
)
linux_plat = linux_platform.scalar_one_or_none()
linux_platform_id = linux_plat.id if linux_plat else None
# Map category patterns to platform types
def _resolve_platform_type(category: str) -> str | None:
cat_lower = category.lower().strip()
if cat_lower in type_to_platform_id:
return cat_lower
if cat_lower in ("linux", "centos", "ubuntu", "debian", "redhat"):
return "linux"
if cat_lower in ("windows", "win"):
return "windows"
if cat_lower in ("mysql", "mariadb", "database", "postgresql"):
return "mysql"
if cat_lower in ("switch", "router", "network", "cisco"):
return "switch"
return None
for cat, node_id in category_to_node_id.items():
plat_type = _resolve_platform_type(cat)
plat_id = type_to_platform_id.get(plat_type) if plat_type else None
await session.execute(
update(Server)
.where(Server.category == cat)
.values(node_id=node_id, platform_id=linux_platform_id)
.values(node_id=node_id, platform_id=plat_id)
)
logger.info(f"Migrated category '{cat}' → node_id={node_id}, platform_id={linux_platform_id}")
logger.info(f"Migrated category '{cat}' → node_id={node_id}, platform_id={plat_id}")
await session.commit()
logger.info(f"Migration complete: {len(category_to_node_id)} categories migrated")
@@ -81,12 +81,15 @@ class PushRetryJobRepositoryImpl:
await self.session.commit()
return job
# Allowed fields for update_status — prevents arbitrary field injection via **kwargs
RETRY_JOB_UPDATABLE_FIELDS = {"retry_count", "max_retries", "next_retry_at", "last_error", "status"}
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):
if key in self.RETRY_JOB_UPDATABLE_FIELDS:
setattr(job, key, value)
await self.session.commit()
return job
+14 -6
View File
@@ -27,6 +27,8 @@ class ServerRepositoryImpl:
async def get_paginated(
self,
category: Optional[str] = None,
platform_id: Optional[int] = None,
node_id: Optional[int] = None,
offset: int = 0,
limit: int = 50,
) -> Tuple[List[Server], int]:
@@ -34,20 +36,26 @@ class ServerRepositoryImpl:
Returns (servers, total_count) DB-level pagination for 2000+ servers.
"""
# Base query
base_query = select(Server)
filters = []
if category:
base_query = base_query.where(Server.category == category)
filters.append(Server.category == category)
if platform_id:
filters.append(Server.platform_id == platform_id)
if node_id:
filters.append(Server.node_id == node_id)
# Count query
count_query = select(func.count(Server.id))
if category:
count_query = count_query.where(Server.category == category)
for f in filters:
count_query = count_query.where(f)
count_result = await self.session.execute(count_query)
total = count_result.scalar_one() or 0
# Paginated data query
data_query = base_query.order_by(Server.id).offset(offset).limit(limit)
data_query = select(Server)
for f in filters:
data_query = data_query.where(f)
data_query = data_query.order_by(Server.id).offset(offset).limit(limit)
result = await self.session.execute(data_query)
servers = list(result.scalars().all())
+7 -1
View File
@@ -56,7 +56,13 @@ AsyncSessionLocal = _SessionLocalProxy()
async def get_async_session():
"""Dependency injection: yield async DB session"""
"""[DEPRECATED] Yield async DB session for FastAPI Depends.
Use DbSessionMiddleware + request.state.db instead (see D7).
This function is kept for backward compatibility but new code
should NOT use it it creates unmanaged sessions that can leak.
Prefer _get_request_session() from server.api.dependencies.
"""
_ensure_engine()
session = _session_factory()
try:
@@ -3,6 +3,7 @@
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.dialects.mysql import insert as mysql_insert
from server.domain.models import Setting
@@ -17,15 +18,13 @@ class SettingRepositoryImpl:
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)
"""Upsert a setting — uses MySQL ON DUPLICATE KEY UPDATE to avoid SELECT-then-INSERT race"""
stmt = mysql_insert(Setting).values(key=key, value=value)
stmt = stmt.on_duplicate_key_update(value=value)
await self.session.execute(stmt)
await self.session.commit()
return setting
result = await self.session.execute(select(Setting).where(Setting.key == key))
return result.scalar_one()
async def get_all(self) -> List[Setting]:
result = await self.session.execute(select(Setting).order_by(Setting.key))
@@ -66,12 +66,15 @@ class SyncLogRepositoryImpl:
await self.session.commit()
return log
async def update_status(self, id: int, status: str, **kwargs) -> SyncLog:
# Allowed fields for update_status — prevents arbitrary field injection via **kwargs
SYNC_LOG_UPDATABLE_FIELDS = {"finished_at", "duration_seconds", "error_message", "diff_summary", "status"}
async def update_status(self, id: int, status: str, **kwargs) -> Optional[SyncLog]:
log = await self.session.get(SyncLog, id)
if log:
log.status = status
for key, value in kwargs.items():
if hasattr(log, key):
if key in self.SYNC_LOG_UPDATABLE_FIELDS:
setattr(log, key, value)
await self.session.commit()
return log
+52 -1
View File
@@ -168,4 +168,55 @@ async def send_telegram_restart_failed() -> bool:
f"🔴 <b>Nexus后端重启失败!</b>\n"
f"请人工介入检查\n"
f"时间: {now}"
)
)
async def send_telegram_sync_complete(
completed: int, failed: int, total: int,
source_path: str, operator: str,
failed_servers: list[str] | None = None,
duration_seconds: int | None = None,
) -> bool:
"""Send push completion notification via Telegram.
- All success 🟢 green
- Partial failure 🟡 yellow with failed server list
- All failed 🔴 red
"""
if not _notify_enabled(getattr(settings, "NOTIFY_SYNC_COMPLETE", "true")):
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
safe_source = html.escape(sanitize_external_message(source_path, max_len=80))
safe_operator = html.escape(operator)
if failed == 0:
icon = "🟢"
title = "推送完成"
elif failed < total:
icon = "🟡"
title = "推送完成(部分失败)"
else:
icon = "🔴"
title = "推送失败"
lines = [
f"{icon} <b>{title}</b>",
f"源路径: <code>{safe_source}</code>",
f"操作人: {safe_operator}",
f"成功: {completed} / 失败: {failed} / 总计: {total}",
]
if duration_seconds is not None:
mins, secs = divmod(duration_seconds, 60)
if mins > 0:
lines.append(f"耗时: {mins}{secs}")
else:
lines.append(f"耗时: {secs}")
if failed_servers:
names = [html.escape(n) for n in failed_servers[:10]]
lines.append(f"失败服务器: {', '.join(names)}")
if len(failed_servers) > 10:
lines.append(f" ...及其他 {len(failed_servers) - 10}")
lines.append(f"时间: {now}")
return await send_telegram("\n".join(lines))
+13 -1
View File
@@ -231,6 +231,8 @@ async def lifespan(app: FastAPI):
async with AsyncSessionLocal() as session:
overridden = await settings.load_settings_from_db(session)
logger.info(f"Settings from DB: {overridden} overrides applied")
# Migrate api_base_url from .env to MySQL if missing (for existing installations)
await settings.ensure_api_base_url_in_db(session)
# 4. Start Redis Pub/Sub subscriber (ADR-010)
from server.api.websocket import start_redis_subscriber, stop_redis_subscriber
@@ -269,6 +271,10 @@ async def lifespan(app: FastAPI):
yield
# ── Shutdown ──
# Cancel WebSocket heartbeat task (H11)
from server.api.websocket import manager as ws_manager
ws_manager.cancel_heartbeat()
for task in _background_tasks:
task.cancel()
try:
@@ -444,4 +450,10 @@ app.include_router(search_router)
# In production, Nginx serves these directly; this is for dev/standalone mode.
WEB_APP_DIR = ROOT_DIR / "web" / "app"
if WEB_APP_DIR.is_dir():
app.mount("/app", StaticFiles(directory=str(WEB_APP_DIR), html=True), name="static_app")
app.mount("/app", StaticFiles(directory=str(WEB_APP_DIR), html=True), name="static_app")
# ── Agent static files: serve web/agent/ at /agent/ (install.sh, agent.py, etc.) ──
# Required by remote install: curl -fsSL {base_url}/agent/install.sh
WEB_AGENT_DIR = ROOT_DIR / "web" / "agent"
if WEB_AGENT_DIR.is_dir():
app.mount("/agent", StaticFiles(directory=str(WEB_AGENT_DIR)), name="static_agent")
+27
View File
@@ -116,9 +116,23 @@ async def health_check(request: Request):
memory = psutil.virtual_memory()
disk = psutil.disk_usage("/")
# Detect Linux distro (e.g. "Ubuntu 24.04", "Debian 12", "CentOS 7")
os_release = ""
try:
import pathlib
os_release_file = pathlib.Path("/etc/os-release")
if os_release_file.exists():
for line in os_release_file.read_text().splitlines():
if line.startswith("PRETTY_NAME="):
os_release = line.split("=", 1)[1].strip('"')
break
except Exception:
pass
system_info = {
"hostname": platform.node(),
"platform": platform.platform(),
"os_release": os_release,
"python_version": platform.python_version(),
"cpu_usage": cpu_percent,
"mem_usage": memory.percent,
@@ -295,6 +309,18 @@ _last_metrics = {}
_last_full_sync = 0
def _os_release() -> str:
"""Read PRETTY_NAME from /etc/os-release (e.g. 'Ubuntu 24.04 LTS')."""
try:
with open("/etc/os-release") as f:
for line in f:
if line.startswith("PRETTY_NAME="):
return line.split("=", 1)[1].strip().strip('"')
except Exception:
pass
return ""
def _alert_thresholds() -> dict:
"""Align with Nexus center defaults (config.py / settings table). Override in config.json if needed."""
t = config.get("alert_thresholds") or {}
@@ -348,6 +374,7 @@ async def send_heartbeat():
"disk_usage": disk,
"hostname": platform.node(),
"platform": platform.platform(),
"os_release": _os_release(),
# agent_time: central uses this to detect clock drift
"agent_time": datetime.now(timezone.utc).isoformat(),
},
+48 -9
View File
@@ -17,7 +17,27 @@ API_KEY=""
SERVER_ID=""
AGENT_PORT="8601"
INSTALL_DIR="/opt/nexus-agent"
VENV_DIR="${INSTALL_DIR}/.venv"
# --- Privilege detection ---
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
else
if command -v sudo >/dev/null 2>&1; then
# Test passwordless sudo (-n = non-interactive)
if sudo -n true 2>/dev/null; then
SUDO="sudo"
echo " Non-root user detected — using sudo for privileged operations."
else
echo "ERROR: sudo requires a password. Configure passwordless sudo first:"
echo " echo \"$(whoami) ALL=(ALL) NOPASSWD:ALL\" | sudo tee /etc/sudoers.d/nexus-agent"
exit 1
fi
else
echo "ERROR: Running as non-root and sudo is not available."
echo " Either run as root or install sudo."
exit 1
fi
fi
while [[ $# -gt 0 ]]; do
case $1 in
@@ -66,9 +86,28 @@ echo " Using: $PYTHON ($($PYTHON --version 2>&1))"
# 2. Create dir + venv
echo "[2/5] Create venv..."
mkdir -p "$INSTALL_DIR"
if [ ! -d "$VENV_DIR" ]; then
"$PYTHON" -m venv "$VENV_DIR"
VENV_DIR="${INSTALL_DIR}/.venv"
$SUDO mkdir -p "$INSTALL_DIR"
# If using sudo, give ownership to current user so venv/pip work without sudo
if [ -n "$SUDO" ]; then
$SUDO chown -R "$(id -u):$(id -g)" "$INSTALL_DIR"
fi
if [ ! -f "$VENV_DIR/bin/activate" ]; then
# Allow venv creation to fail (set -e won't abort) so we can install python3-venv
"$PYTHON" -m venv "$VENV_DIR" 2>/dev/null || true
# venv creation fails when python3-venv is missing — check if activate exists
if [ ! -f "$VENV_DIR/bin/activate" ]; then
echo " venv incomplete — installing python3-venv..."
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq "python$( "$PYTHON" -c 'import sys;print(f"{sys.version_info.major}.{sys.version_info.minor}")' )-venv" 2>/dev/null \
|| $SUDO apt-get install -y -qq python3-venv 2>/dev/null \
|| { echo "ERROR: Cannot install python3-venv. Run: $SUDO apt install python3-venv"; exit 1; }
rm -rf "$VENV_DIR"
"$PYTHON" -m venv "$VENV_DIR"
if [ ! -f "$VENV_DIR/bin/activate" ]; then
echo "ERROR: venv creation failed even after installing python3-venv"
exit 1
fi
fi
fi
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
@@ -105,7 +144,7 @@ echo " Note: inbound TCP ${AGENT_PORT} is NOT required (heartbeat uses HTTPS ou
# 5. systemd + start (bind localhost only)
echo "[5/5] Setup systemd + start..."
cat > /etc/systemd/system/nexus-agent.service << EOF
$SUDO tee /etc/systemd/system/nexus-agent.service > /dev/null << EOF
[Unit]
Description=Nexus Agent
After=network.target
@@ -123,13 +162,13 @@ RestartSec=5
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable nexus-agent
systemctl start nexus-agent
$SUDO systemctl daemon-reload
$SUDO systemctl enable nexus-agent
$SUDO systemctl start nexus-agent
sleep 2
STATUS="running"
systemctl is-active --quiet nexus-agent || STATUS="FAILED"
$SUDO systemctl is-active --quiet nexus-agent || STATUS="FAILED"
HOSTNAME=$(hostname 2>/dev/null || echo "unknown")
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
+38 -3
View File
@@ -86,6 +86,40 @@
let _offset=0;
let _total=0;
const ACTION_NAMES={
'create_server':'添加服务器','update_server':'更新服务器','delete_server':'删除服务器',
'import_servers':'导入服务器','install_agent':'安装 Agent','upgrade_agent':'升级 Agent',
'generate_agent_key':'生成 Agent Key','reveal_install_cmd':'查看安装命令',
'batch_install_agent':'批量安装 Agent','batch_upgrade_agent':'批量升级 Agent',
'sync_files':'文件推送','sync_preview':'推送预览','sync_commands':'批量命令',
'file_delete':'远程删除','file_rename':'远程重命名','file_mkdir':'远程新建目录',
'browse_directory':'目录浏览','file_upload':'文件上传',
'create_credential':'添加凭据','update_credential':'更新凭据','delete_credential':'删除凭据',
'create_script':'添加脚本','update_script':'更新脚本','delete_script':'删除脚本',
'execute_started':'执行开始','execute_command':'执行命令','stop_execution':'停止执行',
'retry_execution':'重试执行','mark_stuck':'标记卡住','auto_stuck':'自动卡住',
'server_auto_stuck':'服务器卡住','script_job_callback':'长任务回调',
'login_success':'登录成功','login_locked':'登录锁定','login_failed':'登录失败',
'login_ip_blocked':'IP不在白名单',
'logout':'登出','change_password':'修改密码',
'setup_totp':'设置 TOTP','enable_totp':'启用 TOTP','disable_totp':'禁用 TOTP',
'reveal_api_key':'查看 API Key','refresh_reuse_attack':'Token 重用攻击',
'refresh_invalid_format':'刷新令牌格式无效','refresh_admin_not_found':'令牌引用管理员不存在',
'refresh_token_mismatch':'Token 不匹配','refresh_legacy_not_found':'旧版刷新令牌未找到',
'update_setting':'修改设置','create_schedule':'新建调度','update_schedule':'更新调度',
'delete_schedule':'删除调度','retry_job':'重试任务','delete_retry':'删除重试',
'create_preset':'添加密码预设','update_preset':'更新密码预设','delete_preset':'删除密码预设',
'reveal_preset':'查看密码预设',
'create_ssh_key_preset':'添加密钥预设','update_ssh_key_preset':'更新密钥预设',
'delete_ssh_key_preset':'删除密钥预设','reveal_ssh_key_preset':'查看密钥预设',
'webssh_connect':'WebSSH 连接','webssh_disconnect':'WebSSH 断开','webssh_token':'WebSSH 令牌',
'refresh_token_mismatch':'Token 不匹配',
'create_platform':'添加平台','update_platform':'更新平台','delete_platform':'删除平台',
'create_node':'添加节点','update_node':'更新节点','delete_node':'删除节点',
'parse_subscription':'解析订阅','update_ip_allowlist':'更新 IP 白名单','toggle_ip_allowlist':'切换 IP 白名单',
};
const TARGET_NAMES={'server':'服务器','credential':'凭据','db_credential':'数据库凭据','script':'脚本','script_execution':'脚本执行','setting':'设置','schedule':'调度','preset':'密码预设','ssh_key_preset':'密钥预设','platform':'平台','node':'节点','admin':'管理员','retry':'重试'};
function clearFilters(){
document.getElementById('dateFrom').value='';
document.getElementById('dateTo').value='';
@@ -110,11 +144,12 @@
const tbody=document.getElementById('auditTbody');
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">暂无日志</td></tr>'}else{
tbody.innerHTML=logs.map(l=>{
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')?'bg-blue-400/10 text-blue-400':'bg-slate-800 text-slate-300';
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')||l.action.includes('install')?'bg-blue-400/10 text-blue-400':'bg-slate-800 text-slate-300';
const actionName=ACTION_NAMES[l.action]||l.action;
return `<tr class="hover:bg-slate-800/30 transition">
<td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${badge}">${esc(l.action)}</span></td>
<td class="px-4 py-3 text-slate-400 text-xs">${esc(l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${badge}">${esc(actionName)}</span></td>
<td class="px-4 py-3 text-slate-400 text-xs">${esc(TARGET_NAMES[l.target_type]||l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
<td class="px-4 py-3 text-slate-600 text-xs">${esc(l.ip_address||'--')}</td>
<td class="px-4 py-3 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
+1 -1
View File
@@ -393,7 +393,7 @@
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
loadCreds();
</script>
+1 -1
View File
@@ -203,7 +203,7 @@
}
// ── Helpers ──
function fmtTime(t) { if (!t) return ''; return new Date(t + 'Z').toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
function fmtTime(t) { if (!t) return ''; var d = new Date(t); if (isNaN(d.getTime())) { d = new Date(t + 'Z'); } return d.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
loadDashboard();
+698 -184
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -76,7 +76,7 @@
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
function fmtTime(t){if(!t)return'';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
loadRetries();
</script>
</body></html>
+1 -1
View File
@@ -275,7 +275,7 @@
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
function fmtTime(t){if(!t)return'';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
loadScheds().then(()=>{
const hid=new URLSearchParams(location.search).get('highlight');
+127 -33
View File
@@ -21,7 +21,7 @@
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="serversTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></th><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">分类</th><th class="text-left px-4 py-3">Agent</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></th><th class="text-left px-4 py-3">Agent 状态</th><th class="text-left px-4 py-3">名称</th><th class="text-left px-4 py-3">地址</th><th class="text-left px-4 py-3">分类</th><th class="text-left px-4 py-3">Agent</th><th class="text-left px-4 py-3">最后心跳</th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
</div>
<div class="mt-4 flex items-center justify-between text-xs text-slate-500"><span id="serverCount">共 -- 台</span><button onclick="loadServers()" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 rounded-lg transition">刷新</button></div>
<!-- Batch Action Bar -->
@@ -32,8 +32,10 @@
</div>
<div class="flex items-center gap-2">
<button onclick="batchCheck()" class="px-4 py-1.5 bg-emerald-600/80 hover:bg-emerald-600 text-white text-sm rounded-lg transition">健康检查</button>
<button onclick="batchInstallAgent()" class="px-4 py-1.5 bg-amber-600/80 hover:bg-amber-600 text-white text-sm rounded-lg transition">安装 Agent</button>
<button onclick="batchUpgradeAgent()" class="px-4 py-1.5 bg-cyan-600/80 hover:bg-cyan-600 text-white text-sm rounded-lg transition">升级 Agent</button>
<button id="btnBatchInstall" onclick="batchInstallAgent()" class="px-4 py-1.5 bg-amber-600/80 hover:bg-amber-600 text-white text-sm rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-amber-600/80">安装 Agent</button>
<button id="btnBatchUpgrade" onclick="batchUpgradeAgent()" class="px-4 py-1.5 bg-cyan-600/80 hover:bg-cyan-600 text-white text-sm rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-cyan-600/80">升级 Agent</button>
<button id="btnBatchDetect" onclick="batchDetectPath()" class="px-4 py-1.5 bg-violet-600/80 hover:bg-violet-600 text-white text-sm rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-violet-600/80">检测路径</button>
<button id="btnBatchUninstall" onclick="batchUninstallAgent()" class="px-4 py-1.5 bg-red-700/80 hover:bg-red-700 text-white text-sm rounded-lg transition disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-700/80">卸载 Agent</button>
<button onclick="batchPush()" class="px-4 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">批量推送</button>
<button onclick="batchDelete()" class="px-4 py-1.5 bg-red-600/80 hover:bg-red-600 text-white text-sm rounded-lg transition">批量删除</button>
</div>
@@ -66,12 +68,13 @@
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">系统版本</div><div id="siOS" class="text-lg font-medium text-white truncate">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">发行版</div><div id="siOSRelease" class="text-lg font-medium text-white truncate">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内核平台</div><div id="siPlatform" class="text-sm font-medium text-slate-300 truncate">--</div></div>
</div>
<div id="sysInfoMeta" class="mt-4 grid grid-cols-3 gap-4 text-sm">
<div><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300">--</span></div>
<div><span class="text-slate-500">目标路径:</span><span id="siTarget" class="text-slate-300">--</span></div>
<div><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300">--</span></div>
<div id="sysInfoMeta" class="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div id="siDescRow"><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300"></span></div>
<div id="siTargetRow"><span class="text-slate-500">目标路径:</span><code id="siTarget" class="text-slate-300 text-xs"></code></div>
<div id="siAuthRow"><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300"></span></div>
</div>
</div>
<!-- Tab Content: Sync Logs -->
@@ -159,9 +162,10 @@
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 class="text-white font-semibold text-lg">批量导入服务器</h2>
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-400 space-y-1">
<p>1. <a href="javascript:void(0)" onclick="downloadCsvTemplate()" class="text-brand-light underline">下载 CSV 模板</a></p>
<p>2. 填写服务器信息(名称和地址为必填列</p>
<p>3. 上传 CSV 文件,最多 500 行</p>
<p>1. <a href="/app/servers_import_template.csv" download="servers_import_template.csv" class="text-brand-light underline">下载 CSV 模板</a></p>
<p>2. 按模板格式填写(<span class="text-slate-200">名称</span><span class="text-slate-200">地址</span>必填,其余选填</p>
<p>3. 认证方式填 <span class="text-slate-200">password</span><span class="text-slate-200">key</span>,默认 password</p>
<p>4. 上传 CSV 文件,最多 500 行</p>
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">选择 CSV 文件</label>
@@ -209,6 +213,17 @@
let _serversCache={};
let _currentDetailTab='info';
let _selectedIds=new Set();
let _apiBaseUrl=null; // cached from /api/servers/meta/api_base_url
async function loadApiBaseUrl(){
try{
const r=await apiFetch(API+'/servers/meta/api_base_url');
if(r&&r.ok){
const data=await r.json();
_apiBaseUrl=data.api_base_url||null;
}
}catch(e){/* ignore — _apiBaseUrl stays null */}
}
async function loadServers(){
try{
@@ -238,7 +253,7 @@
if(!servers.length){tbody.innerHTML='<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">暂无服务器</td></tr>';return}
tbody.innerHTML=servers.map(s=>`<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
<td class="px-4 py-3" onclick="event.stopPropagation()"><input type="checkbox" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand srv-chk" data-id="${s.id}" onchange="toggleSelect(${s.id},this.checked)" ${_selectedIds.has(s.id)?'checked':''}></td>
<td class="px-4 py-3"><span class="inline-block w-2.5 h-2.5 rounded-full ${s.is_online?'bg-green-400':'bg-red-400'} ${s.is_online?'':'animate-pulse'}"></span></td>
<td class="px-4 py-3">${s.agent_version?(s.is_online?'<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-green-400"></span><span class="text-green-400">在线</span></span>':'<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-red-400 animate-pulse"></span><span class="text-red-400">离线</span></span>'):'<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-slate-600"></span><span class="text-slate-500">未安装</span></span>'}</td>
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}${driftBadge(s)}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.category||'--')}</td>
@@ -288,16 +303,24 @@
document.getElementById('siMem').className='text-2xl font-bold '+(mem>80?'text-red-400':mem>60?'text-yellow-400':'text-green-400');
document.getElementById('siDisk').textContent=(disk!=null?disk.toFixed(1)+'%':'--');
document.getElementById('siDisk').className='text-2xl font-bold '+(disk>80?'text-red-400':disk>60?'text-yellow-400':'text-green-400');
document.getElementById('siOS').textContent=sys.os||sys.platform||'--';
document.getElementById('siOSRelease').textContent=sys.os_release||'--';
document.getElementById('siPlatform').textContent=sys.platform||'--';
}else{
document.getElementById('siCPU').textContent='--';
document.getElementById('siMem').textContent='--';
document.getElementById('siDisk').textContent='--';
document.getElementById('siOS').textContent='--';
document.getElementById('siOSRelease').textContent='--';
document.getElementById('siPlatform').textContent='--';
}
document.getElementById('siDesc').textContent=s.description||'--';
document.getElementById('siTarget').textContent=s.target_path||'--';
document.getElementById('siAuth').textContent=s.auth_method==='password'?'密码':'SSH密钥';
var desc=s.description||'';
var target=s.target_path||'';
var auth=s.auth_method;
document.getElementById('siDescRow').style.display=desc?'':'none';
document.getElementById('siDesc').textContent=desc;
document.getElementById('siTargetRow').style.display=target?'':'none';
document.getElementById('siTarget').textContent=target;
document.getElementById('siAuthRow').style.display=auth?'':'none';
document.getElementById('siAuth').textContent=auth==='password'?'密码':'SSH密钥';
}
async function loadSyncLogs(){
@@ -352,7 +375,7 @@
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30">● Agent 在线</span>'
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500 border border-slate-700">○ Agent 离线 / 未安装</span>';
const base_url_conf=!!(s._base_url);
const base_url_conf=!!(_apiBaseUrl);
const noKey=!s.agent_api_key_set;
let warnings='';
if(!base_url_conf)warnings+=`<div class="text-amber-400 text-xs">⚠ NEXUS_API_BASE_URL 未配置,请在系统设置中填写主站对外 URL,才能生成安装命令</div>`;
@@ -395,6 +418,10 @@
${s.is_online?`<button onclick="upgradeAgent()" id="upgradeAgentBtn"
class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="下载最新 agent.py 并重启服务">
⬆ 升级 Agent
</button>
<button onclick="uninstallAgent()" id="uninstallAgentBtn"
class="px-3 py-2 bg-red-900/60 hover:bg-red-800 text-white text-sm rounded-lg transition" title="停止 Agent 服务并删除安装目录">
🗑 卸载 Agent
</button>`:''}
<span class="text-slate-500 text-xs">安装约需 30-60 秒;安装完成后 Agent 发送首次心跳约需再等 60 秒,状态徽章将自动更新为 <span class="text-green-400">● Agent 在线</span></span>
</div>
@@ -457,6 +484,32 @@
finally{if(btn){btn.disabled=false;btn.textContent='⬆ 升级 Agent'}}
}
async function uninstallAgent(){
if(!selectedServerId)return;
if(!confirm('确定卸载该服务器的 Agent\n将停止服务、删除 /opt/nexus-agent 目录和日志文件,并清除 Agent 状态。'))return;
const btn=document.getElementById('uninstallAgentBtn');
if(btn){btn.disabled=true;btn.textContent='卸载中...'}
const logEl=document.getElementById('remoteInstallLog');
const logText=document.getElementById('remoteInstallLogText');
logEl?.classList.remove('hidden');
logText.textContent='正在停止 Agent 服务并清理安装目录...\n';
try{
const r=await apiFetch(API+'/servers/'+selectedServerId+'/uninstall-agent',{method:'POST',headers:apiHeadersJSON()});
if(!r){logText.textContent+='请求失败\n';return}
const d=await r.json();
if(r.ok&&d.success){
logText.textContent+=d.stdout||'';
logText.textContent+='\n✓ Agent 卸载成功!';
toast('success','Agent 卸载成功');
setTimeout(loadServers,1000);
}else{
logText.textContent+=(d.detail||'卸载失败')+'\n';
toast('error','卸载失败');
}
}catch(e){logText.textContent+='错误: '+e.message;toast('error','卸载请求失败')}
finally{if(btn){btn.disabled=false;btn.textContent='🗑 卸载 Agent'}}
}
function copyAgentCmd(){
const cmd=document.getElementById('agentCmdText')?.textContent;
if(cmd){
@@ -773,7 +826,7 @@
toast('success','密钥已重新生成并复制到剪贴板,已立即生效');
}catch(e){toast('error','生成密钥失败')}
}
function fmtTime(t){if(!t)return'--';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime())){d=new Date(t+'Z')}return d.toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
// Clear stale autocomplete values on page load (must outlast browser autofill timing)
requestAnimationFrame(()=>requestAnimationFrame(()=>{document.getElementById('searchInput').value=''}));
@@ -815,6 +868,24 @@
// Update select-all state
const total=document.querySelectorAll('.srv-chk').length;
document.getElementById('selectAll').checked=total>0&&count===total;
// Agent install/upgrade button states
let allInstalled=true, allNotInstalled=true;
_selectedIds.forEach(id=>{
const s=_serversCache[id];
if(s){
const hasAgent=!!s.agent_version;
if(!hasAgent) allInstalled=false;
if(hasAgent) allNotInstalled=false;
}
});
const btnInstall=document.getElementById('btnBatchInstall');
const btnUpgrade=document.getElementById('btnBatchUpgrade');
const btnDetect=document.getElementById('btnBatchDetect');
const btnUninstall=document.getElementById('btnBatchUninstall');
if(btnInstall) btnInstall.disabled=allInstalled; // all have agent → disable install
if(btnUpgrade) btnUpgrade.disabled=allNotInstalled; // none have agent → disable upgrade
if(btnDetect) btnDetect.disabled=allNotInstalled; // need agent for SSH connection
if(btnUninstall) btnUninstall.disabled=allNotInstalled; // need agent for uninstall
}
function batchPush(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
@@ -867,18 +938,6 @@
}
// ── CSV Import ──
async function downloadCsvTemplate(){
try{
const r=await apiFetch(API+'/servers/import/template');
if(!r||!r.ok){toast('error','下载模板失败');return}
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;a.download='servers_import_template.csv';
document.body.appendChild(a);a.click();document.body.removeChild(a);
URL.revokeObjectURL(url);
toast('success','模板已下载');
}catch(e){toast('error','下载失败: '+e.message)}
}
function showImportModal(){
document.getElementById('importFile').value='';
document.getElementById('importProgress').classList.add('hidden');
@@ -956,6 +1015,40 @@
}catch(e){toast('error','批量升级失败: '+e.message);hideBatchAgentModal()}
}
async function batchDetectPath(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
const ids=[..._selectedIds];
if(!confirm(`确定对 ${ids.length} 台服务器自动检测 target_path\n将在 /www/wwwroot 下搜索 workerman.bat,找到后自动设置目标路径。`))return;
document.getElementById('batchAgentTitle').textContent='自动检测路径';
document.getElementById('batchAgentModal').classList.remove('hidden');
document.getElementById('batchAgentProgress').classList.remove('hidden');
document.getElementById('batchAgentResult').classList.add('hidden');
try{
const r=await apiFetch(API+'/servers/batch/detect-path',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:ids})});
if(!r){toast('error','请求失败');return}
const data=await r.json();
_showBatchAgentResult(data.results||[]);
loadServers();
}catch(e){toast('error','检测失败: '+e.message);hideBatchAgentModal()}
}
async function batchUninstallAgent(){
if(!_selectedIds.size){toast('warning','请先选择服务器');return}
const ids=[..._selectedIds];
if(!confirm(`确定对 ${ids.length} 台已安装 Agent 的服务器执行卸载?\n将停止服务、删除 /opt/nexus-agent 目录和日志文件,并清除 Agent 状态。`))return;
document.getElementById('batchAgentTitle').textContent='批量卸载 Agent';
document.getElementById('batchAgentModal').classList.remove('hidden');
document.getElementById('batchAgentProgress').classList.remove('hidden');
document.getElementById('batchAgentResult').classList.add('hidden');
try{
const r=await apiFetch(API+'/servers/batch/uninstall-agent',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_ids:ids})});
if(!r){toast('error','请求失败');return}
const data=await r.json();
_showBatchAgentResult(data.results||[]);
loadServers();
}catch(e){toast('error','卸载失败: '+e.message);hideBatchAgentModal()}
}
function _showBatchAgentResult(results){
document.getElementById('batchAgentProgress').classList.add('hidden');
document.getElementById('batchAgentResult').classList.remove('hidden');
@@ -965,11 +1058,12 @@
document.getElementById('batchAgentFail').textContent=fail;
const details=document.getElementById('batchAgentDetails');
details.innerHTML=results.map(r=>{
if(r.success)return `<div class="flex items-center gap-2 text-xs text-emerald-400"><span></span><span>${esc(r.server_name)}</span></div>`;
return `<div class="flex items-center gap-2 text-xs text-red-400"><span></span><span>${esc(r.server_name)}</span><span class="text-slate-500 truncate">${esc(r.error)}</span></div>`;
if(r.success)return `<div class="flex items-center gap-2 text-xs text-emerald-400"><span></span><span>${esc(r.server_name)}</span>${r.stdout?`<span class="text-slate-400">${esc(r.stdout)}</span>`:''}</div>`;
return `<div class="flex items-center gap-2 text-xs text-red-400"><span></span><span>${esc(r.server_name)}</span><span class="text-slate-500 truncate" title="${esc(r.error)}">${esc(r.error)}</span></div>`;
}).join('');
}
loadApiBaseUrl();
loadServers().then(()=>{
const hid=new URLSearchParams(location.search).get('highlight');
if(hid)selectServer(parseInt(hid));
+1 -1
View File
@@ -282,7 +282,7 @@
const SECTIONS={
brand:{keys:['system_name','system_title'],labels:{system_name:'系统名称',system_title:'页面标题'}},
alert:{keys:['cpu_alert_threshold','mem_alert_threshold','disk_alert_threshold'],labels:{cpu_alert_threshold:'CPU 告警阈值',mem_alert_threshold:'内存告警阈值',disk_alert_threshold:'磁盘告警阈值'}},
infra:{keys:['db_pool_size','db_max_overflow','heartbeat_timeout','redis_url'],labels:{db_pool_size:'连接池大小',db_max_overflow:'最大溢出',heartbeat_timeout:'心跳超时(秒)',redis_url:'Redis URL'}},
infra:{keys:['api_base_url','db_pool_size','db_max_overflow','heartbeat_timeout','redis_url'],labels:{api_base_url:'主站对外 URL',db_pool_size:'连接池大小',db_max_overflow:'最大溢出',heartbeat_timeout:'心跳超时(秒)',redis_url:'Redis URL'}},
telegram:{keys:['telegram_bot_token','telegram_chat_id'],labels:{telegram_bot_token:'Bot Token',telegram_chat_id:'Chat ID'}},
};
let _allSettings={};
+74 -6
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: true, panelOpen: true, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{height:calc(100vh - 48px)}#terminalWrap.fs{height:100vh;position:fixed;inset:0;z-index:100}</style></head>
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: true, panelOpen: true, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{flex:1;min-height:0}#terminalWrap.fs{position:fixed;inset:0;z-index:100}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<!-- Left Sidebar -->
@@ -26,7 +26,7 @@
</header>
<!-- Terminal -->
<div id="terminalWrap" class="bg-slate-950"><div id="terminal"></div></div>
<div id="terminalWrap" class="bg-slate-950 flex flex-col relative"><div id="terminal" class="flex-1 min-h-0"></div><div id="disconnectOverlay" class="hidden absolute inset-0 bg-slate-950/80 backdrop-blur-sm flex flex-col items-center justify-center z-50"><div class="text-slate-300 text-lg mb-4">连接已断开</div><div class="flex gap-3"><button onclick="reconnect()" class="px-5 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">重新连接</button><a href="/app/servers.html" class="px-5 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">返回列表</a></div></div><div id="cmdBar" class="bg-slate-900/95 border-t border-slate-800 px-3 py-2 flex items-center gap-2 shrink-0"><span class="text-brand-light text-sm font-mono select-none">&#10095;</span><input id="cmdInput" type="text" placeholder="输入命令,Enter 发送…" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-white text-sm font-mono placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-brand/50" autocomplete="off" spellcheck="false"><button onclick="sendCmd()" class="px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded transition">发送</button><div class="flex gap-1 border-l border-slate-700 pl-2"><button onclick="sendCtrl('c')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+C 中断">&#8963;C</button><button onclick="sendCtrl('d')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+D 退出">&#8963;D</button><button onclick="sendKey('\t')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Tab 补全">Tab</button></div></div></div>
</div>
<!-- Right Server Panel -->
@@ -162,6 +162,8 @@
ws.onopen = () => {
$d().connected = true;
document.getElementById('disconnectOverlay').classList.add('hidden');
document.getElementById('cmdBar').classList.remove('opacity-50','pointer-events-none');
term.focus();
};
@@ -188,6 +190,8 @@
case 'CLOSE':
$d().connected = false;
term.write('\r\n\x1b[33m━━ 连接已关闭 ━━\x1b[0m\r\n');
document.getElementById('disconnectOverlay').classList.remove('hidden');
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
break;
}
} catch (err) {
@@ -197,6 +201,8 @@
ws.onclose = (ev) => {
$d().connected = false;
document.getElementById('disconnectOverlay').classList.remove('hidden');
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
if (ev.code === 4001) {
term.write('\r\n\x1b[31m⚠ 认证失败,请重新登录\x1b[0m\r\n');
setTimeout(() => location.href = '/app/login.html', 2000);
@@ -211,6 +217,8 @@
ws.onerror = () => {
$d().connected = false;
document.getElementById('disconnectOverlay').classList.remove('hidden');
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
term.write('\r\n\x1b[31m⚠ WebSocket错误\x1b[0m\r\n');
};
}
@@ -244,10 +252,24 @@
// ── Disconnect ──
function disconnect() {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'CLOSE' }));
ws.close(1000, 'User disconnect');
if (ws) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'CLOSE' }));
ws.close(1000, 'User disconnect');
}
ws = null;
}
$d().connected = false;
document.getElementById('disconnectOverlay').classList.remove('hidden');
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
}
// ── Reconnect ──
function reconnect() {
document.getElementById('disconnectOverlay').classList.add('hidden');
document.getElementById('cmdBar').classList.remove('opacity-50','pointer-events-none');
term.clear();
connect();
}
// ── Keyboard shortcuts ──
@@ -269,7 +291,53 @@
} catch(e){ console.warn('Server info load error:', e); }
})();
// ── Init ──
// ── Command Input Bar ──
const cmdInput = document.getElementById('cmdInput');
let cmdHistory = [];
let cmdHistoryIdx = -1;
cmdInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendCmd();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (cmdHistoryIdx < cmdHistory.length - 1) {
cmdHistoryIdx++;
cmdInput.value = cmdHistory[cmdHistory.length - 1 - cmdHistoryIdx];
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (cmdHistoryIdx > 0) {
cmdHistoryIdx--;
cmdInput.value = cmdHistory[cmdHistory.length - 1 - cmdHistoryIdx];
} else {
cmdHistoryIdx = -1;
cmdInput.value = '';
}
}
});
function sendCmd() {
const cmd = cmdInput.value;
if (!cmd || ws?.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'DATA', data: cmd + '\r' }));
cmdHistory.push(cmd);
cmdHistoryIdx = -1;
cmdInput.value = '';
}
function sendCtrl(key) {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'DATA', data: String.fromCharCode(key.toLowerCase().charCodeAt(0) - 96) }));
}
function sendKey(key) {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'DATA', data: key }));
}
// ── Init ──
loadServerList();
connect();
</script>