Compare commits

..

20 Commits

Author SHA1 Message Date
Nexus Agent f11ad35708 chore(scripts): 推送提权验证脚本与温胜夜生产验证报告
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
2026-06-11 23:28:59 +08:00
Nexus Agent 84b388fae2 fix(sync): rsync 推送非 root 自动 sudo 提权
文件推送与文件管理对齐:非 root 默认 auto_sudo 时使用 sudo -n rsync;
新增批量 sudoers 补丁脚本以补全 rsync 白名单。
2026-06-11 23:20:45 +08:00
Nexus Agent 0a5111a959 docs(audit): 浏览器拖动手柄修复审计条目
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:20:02 +08:00
Nexus Agent 31846c4212 fix(browser): 修复浮动浏览器最小化条与面板无法拖动
迷你窗口使用独立最小尺寸与拖动手柄;展开态增加左侧拖拽条。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:19:49 +08:00
Nexus Agent 2e8daad802 fix(browser): 不可关闭、最小化可拖拽与重启
移除关闭入口;最小化为可拖动浮动条并持久化 minimized_rect;重启保留浏览历史。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:15:40 +08:00
Nexus Agent a94d340ec0 docs(audit): 补全全局浮动浏览器 Closure 与文件清单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:11:21 +08:00
Nexus Agent 8356425814 feat: 全局浮动浏览器、记录持久化与角落快捷入口
浮动面板可最小化至右下角、左下角随时展开;标签与浏览记录写入 MySQL,切换菜单保持活动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 20:10:57 +08:00
Nexus Agent 2a6f526bc9 feat: 内置 Web 浏览器页与服务器站点入口
侧栏浏览器页 iframe 预览 http(s) 站点;服务器列表「站点」由 domain 跳转,禁止嵌入时可新标签打开。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:30:44 +08:00
Nexus Agent 996403ad86 docs(audit): 文件上传 www 权限 fixup 审计记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:24:06 +08:00
Nexus Agent 4e117c04ea feat(files): 上传后自动 chown www:www 与文件 chmod
复用推送的 RSYNC_PUSH 配置,上传成功即 SSH 修正属主/权限;失败时仍保留文件并返回 warning。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:23:31 +08:00
Nexus Agent 79105dcec8 fix(files): 改权限后列表不更新及 chown 连带失败
ETag 纳入权限指纹避免 304 旧数据;chmod/chown 分步执行;仅变更属主/属组时才提交 chown。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:12:57 +08:00
Nexus Agent d3c0e57a08 fix(servers): 搜索改为手动触发,进页默认全量列表
不再自动恢复 last 搜索;下拉选历史、回车或清空才请求;输入过程不触发过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:54:46 +08:00
Nexus Agent af912662de fix: combobox 清空 null 导致服务器列表加载失败
v-combobox 清空时 search 为 null 使 trim() 抛错;422 响应剥离不可序列化的 ctx 避免 500。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:52:49 +08:00
Nexus Agent c69e45a571 docs: 服务器搜索历史部署审计与门控记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:49:43 +08:00
Nexus Agent 130cc840c7 feat: 服务器搜索历史持久化到 MySQL
按管理员将 Servers 页搜索记录存入 admin_ui_preferences,换设备登录仍可恢复;首次自动迁移 localStorage。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:46:52 +08:00
Nexus Agent 704764bd7e fix: 订阅白名单多 worker 同步并统一上传上限 500MB
保存 IP 白名单时同步刷新并 Redis 广播 login_* 设置;推送/文件上传与 Nginx 模板对齐为 500MB,避免生产 50m/100m 导致大文件失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 17:39:26 +08:00
Nexus Agent 426bafd169 Audit: list package-lock.json in RDP removal review. 2026-06-10 23:23:28 +08:00
Nexus Agent dc4593f8b5 Remove browser RDP (guacd/Guacamole) feature entirely.
Product decision: drop 3389 remote desktop page, API, guacd sidecar, and related tests after unresolved target-side black screen issues.
2026-06-10 23:23:09 +08:00
Nexus Agent 341b130d9b fix(rdp): guacd 后台读取队列 + 隧道超时与尺寸
独立 guacd reader 避免 WS 竞争丢数据;ping 回显与 sync 已生产验证;前端 90s 超时与 OPEN 后发尺寸。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 21:54:50 +08:00
Nexus Agent af47b24450 fix(rdp): connect 前挂载画布并修复 RdpPage 布局
Guacamole display 需在 DOM 中才能完成 sync;canvasHost 独立挂载避免 Vue 重绘清除画布。
2026-06-10 21:45:54 +08:00
103 changed files with 4007 additions and 2150 deletions
+4 -3
View File
@@ -12,7 +12,8 @@ upstream nexus_api {
# WebSocket log without query string (JWT must not hit access_log)
log_format nexus_ws '$remote_addr - [$time_local] "$request_method $uri" $status $body_bytes_sent';
client_max_body_size 100m;
client_max_body_size 500m;
client_body_timeout 600s;
location /api/ {
proxy_pass http://nexus_api;
@@ -21,8 +22,8 @@ location /api/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_connect_timeout 10s;
}
+90
View File
@@ -1567,3 +1567,93 @@
{"ts":"2026-06-10T20:57:52+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-10T20:57:52+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-10T20:57:52+08:00","date":"2026-06-10","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md 32lines"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md 32lines"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md 36lines"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-guacd-handshake-fix.md 30lines"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md 48lines"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md 48lines"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md 48lines"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-servers-search-history.md 44lines"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"audit","result":"BLOCK","detail":"file not found"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"no audit file"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"4/7"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-servers-search-history.md 44lines"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-servers-search-history.md"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
+1 -1
View File
@@ -416,7 +416,7 @@ server {
}
# Upload limit
client_max_body_size 100m;
client_max_body_size 500m;
# Well-known for SSL cert validation
location ~ \\.well-known {
+1 -1
View File
@@ -75,5 +75,5 @@ server {
error_log {{LOG_DIR}}/{{SERVER_NAME}}_nexus.error.log;
# ── Upload limit ──
client_max_body_size 100m;
client_max_body_size 500m;
}
+1 -1
View File
@@ -109,5 +109,5 @@ server {
error_log {{LOG_DIR}}/{{SERVER_NAME}}.error.log;
# ── Upload limit ──
client_max_body_size 100m;
client_max_body_size 500m;
}
-8
View File
@@ -40,10 +40,6 @@ services:
ports:
- "${REDIS_PUBLISH_PORT:-6379}:6379"
guacd:
image: guacamole/guacd:1.5.5
restart: unless-stopped
nexus:
build:
context: .
@@ -54,8 +50,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
guacd:
condition: service_started
ports:
- "${NEXUS_PUBLISH_PORT:-8600}:8600"
environment:
@@ -75,8 +69,6 @@ services:
NEXUS_API_KEY: ${NEXUS_API_KEY:-}
NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:-}
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:-http://localhost:8600}
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
env_file:
- path: docker/.env
required: false
-10
View File
@@ -11,15 +11,7 @@
name: nexus-prod
services:
guacd:
image: guacamole/guacd:1.5.5
restart: unless-stopped
networks:
- nexus-internal
nexus:
depends_on:
- guacd
build:
context: ..
dockerfile: Dockerfile.prod
@@ -46,8 +38,6 @@ services:
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:?set NEXUS_API_BASE_URL}
NEXUS_DB_POOL_SIZE: ${NEXUS_DB_POOL_SIZE:-30}
NEXUS_DB_MAX_OVERFLOW: ${NEXUS_DB_MAX_OVERFLOW:-20}
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
volumes:
- nexus-state:/var/lib/nexus
- nexus-web-data:/app/web/data
@@ -0,0 +1,66 @@
# 审计 — 移除浏览器 RDP 功能
**Changelog**: `docs/changelog/2026-06-10-rdp-feature-removal.md`
## 变更文件
| 文件 | 说明 |
|------|------|
| `server/api/rdp.py` | 删除 WS 隧道 |
| `server/api/rdp_hosts.py` | 删除 CRUD |
| `server/api/servers.py` | 删除 `/rdp-connect` 与 rdp 字段 |
| `server/api/schemas.py` | 删除 rdp_* 请求字段 |
| `server/main.py` | 注销 rdp 路由 |
| `server/config.py` | 删除 GUACD_* |
| `server/domain/models/__init__.py` | 删除 RdpHost |
| `server/infrastructure/database/migrations.py` | DROP rdp_hosts |
| `server/infrastructure/database/rdp_host_repo.py` | 删除 |
| `server/infrastructure/guacamole/__init__.py` | 删除 |
| `server/infrastructure/guacamole/protocol.py` | 删除 |
| `server/infrastructure/guacamole/tunnel.py` | 删除 |
| `server/infrastructure/guacamole/ws_tunnel.py` | 删除 |
| `server/utils/rdp_config.py` | 删除 |
| `docker-compose.yml` | 删除 guacd 服务 |
| `docker/docker-compose.prod.yml` | 删除 guacd 侧车 |
| `frontend/src/App.vue` | 移除侧栏 3389 |
| `frontend/src/router/index.ts` | 移除 /rdp 路由 |
| `frontend/src/composables/useRoutePrefetch.ts` | 移除预载 |
| `frontend/src/pages/RdpPage.vue` | 删除 |
| `frontend/src/components/rdp/RdpHostFormDialog.vue` | 删除 |
| `frontend/src/composables/rdp/useRdpSession.ts` | 删除 |
| `frontend/src/composables/rdp/useRdpHostList.ts` | 删除 |
| `frontend/src/types/guacamole.d.ts` | 删除 |
| `frontend/src/types/api.ts` | 删除 rdp 响应字段 |
| `frontend/src/api/index.ts` | 注释更新 |
| `frontend/package.json` | 移除 guacamole-common-js |
| `frontend/package-lock.json` | 锁文件同步 |
| `frontend/e2e/helpers.mjs` | 移除侧栏项 |
| `tests/test_rdp_hosts.py` | 删除 |
| `tests/test_rdp_config.py` | 删除 |
| `tests/test_guacamole_protocol.py` | 删除 |
## Step 3(规则扫描)
| 规则 | 结论 | 说明 |
|------|------|------|
| 攻击面 | PASS | 移除 WS 隧道与 guacd 依赖,减少暴露 |
| 密钥 | PASS | rdp_hosts 表 DROP;无新增凭据路径 |
| 鉴权 | N/A | 删除端点,无新 API |
| 静默吞错 | PASS | 无新增异常处理 |
| 依赖 | PASS | 移除 guacamole-common-js |
## Closure(走读)
| 文件 | 结论 |
|------|------|
| `main.py` | SAFE — 无残留 rdp import |
| `servers.py` | SAFE — 无 rdp_config 引用 |
| `migrations.py` | SAFE — DROP IF EXISTS 幂等 |
| `App.vue` | SAFE — 菜单项已移除 |
## DoD
- [x] changelog + 本审计
- [x] `local_verify` + frontend type-check
- [ ] 生产部署与健康检查
- [ ] 侧栏无「3389远程」
+36
View File
@@ -0,0 +1,36 @@
# 审计 — 内置 Web 浏览器
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/pages/BrowserPage.vue` | iframe 浏览器页 |
| `frontend/src/composables/useEmbeddedBrowser.ts` | 导航历史 |
| `frontend/src/utils/browserUrl.ts` | URL 白名单校验 |
| `frontend/src/router/index.ts` | `/browser` 路由 |
| `frontend/src/App.vue` | 侧栏 + 全屏布局 |
| `frontend/src/pages/ServersPage.vue` | 站点入口 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无 SSRF(无后端 fetch | PASS |
| H2 | 仅 http/https | PASS — normalizeBrowserUrl |
| H3 | iframe sandbox | PASS |
| H4 | 鉴权 | PASS — 走路由 guard |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H4 | PASS | 纯前端 iframe |
## DoD
- [x] type-check
- [x] changelog
## 验证
`#/browser` 与服务器「站点」按钮;禁止嵌入站点用新标签打开。
@@ -0,0 +1,37 @@
# 审计 — 文件管理 chmod 不生效修复
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/application/services/files_browse_service.py` | ETag 含 permissions/owner/group |
| `server/api/sync_v2.py` | chmod/chown 分步执行 |
| `frontend/src/components/FilePermissionDialog.vue` | 仅变更时提交 owner/group |
| `frontend/src/stores/files.ts` | 不继承旧 etag |
| `tests/test_files_browse_etag.py` | 权限变更 etag 测试 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 304 不返回过期权限 | PASS — ETag 含 permissions |
| H2 | chmod 不被 chown 连带失败 | PASS — 分步执行 |
| H3 | CUD 审计保留 | PASS — `_audit_sync` 未删 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | compute_browse_etag |
| H2 | PASS | sync_v2 chmod_file |
| H3 | PASS | audit_bits 仍在 |
## DoD
- [x] pytest 14 passed
- [x] type-check 通过
- [x] changelog
## 验证
文件页改 644→755 → 列表权限列更新;仅改 mode 不填 chown 时不发 owner 字段。
@@ -0,0 +1,37 @@
# 审计 — 文件管理上传后自动 www 权限
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/files_upload_permissions.py` | 解析 RSYNC_PUSH 配置,SSH chown/chmod |
| `server/api/sync_v2.py` | `_sftp_upload_one` 上传后 fixup + 响应/审计 |
| `frontend/src/composables/files/useFilesActions.ts` | 权限修正失败 warning |
| `tests/test_files_upload_permissions.py` | 解析与 apply 单测 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 复用推送权限配置,不硬编码 | PASS — `RSYNC_PUSH_CHOWN/CHMOD` |
| H2 | 上传成功不因 fixup 失败回滚 | PASS — 仅返回 `permission_fixup.error` |
| H3 | 命令注入防护 | PASS — `_RSYNC_*_RE` + `shlex.quote` |
| H4 | CUD 审计 | PASS — audit_detail 含 chown/chmod |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | `upload_permission_plan()` |
| H2 | PASS | sync_v2 不 raise on fixup fail |
| H3 | PASS | sync_engine_v2 校验 + quote |
| H4 | PASS | `_audit_sync("file_upload", ...)` |
## DoD
- [x] pytest `test_files_upload_permissions.py` 7 passed
- [x] changelog `2026-06-11-files-upload-www-permissions.md`
## 验证
文件页上传 → 远端 `ls -la``www www`;sudo 白名单不足时界面 warning、文件仍保留。
@@ -0,0 +1,45 @@
# 审计 — 全局浮动浏览器
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/browser_ui_state.py` | URL 校验、visit 上限 |
| `server/api/browser.py` | `/api/browser/state` |
| `server/main.py` | 注册 browser 路由 |
| `frontend/src/composables/useGlobalBrowser.ts` | 全局状态 |
| `frontend/src/components/GlobalBrowserPanel.vue` | 浮动面板 |
| `frontend/src/composables/useFloatingPanel.ts` | 可配置最小尺寸,支持迷你条拖动 |
| `frontend/src/pages/BrowserPage.vue` | 打开全局窗并返回 |
| `frontend/src/App.vue` | 挂载 GlobalBrowserPanel |
| `frontend/src/pages/ServersPage.vue` | 站点按钮 |
| `frontend/src/composables/useEmbeddedBrowser.ts` | 删除(由 global 替代) |
| `tests/test_browser_ui_state.py` | 单测 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 无 SSRF | PASS — 无服务端 fetch |
| H2 | URL 白名单 | PASS — http/https only |
| H3 | 按管理员隔离 | PASS — admin_ui_preferences |
| H4 | iframe sandbox | PASS |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | 纯 iframe,无 proxy |
| H2 | PASS | browser_ui_state._is_safe_url |
| H3 | PASS | AdminUiPreference per admin |
| H4 | PASS | GlobalBrowserPanel sandbox attr |
## DoD
- [x] pytest test_browser_ui_state.py
- [x] type-check
- [x] changelog
## 验证
浮动打开 → 最小化可拖动浮动条 → 左下角展开 → 不可关闭仅可重启 → 切换菜单 iframe 仍在。
@@ -0,0 +1,41 @@
# 审计 — rsync 推送 sudo 提权 + 批量 sudoers 补丁
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/utils/rsync_elevation.py` | 非 root 默认 sudo rsync |
| `server/application/services/sync_engine_v2.py` | `_rsync_push` 接入 |
| `server/application/services/files_sudoers_service.py` | sudoers 模板与安装 |
| `server/api/sync_v2.py` | 诊断写入 sudo 回退 |
| `server/api/servers.py` | setup-files-sudo 复用 service |
| `scripts/batch_patch_files_sudoers.py` | 批量补丁脚本 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | sudoers 无用户注入 | PASS — `build_nexus_files_sudoers` 固定白名单 |
| H2 | rsync-path 无外部输入 | PASS — 常量 `sudo -n rsync` |
| H3 | 密码不经 argv | PASS — stdin `sudo -S` |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | files_sudoers_service |
| H2 | PASS | rsync_elevation.py |
| H3 | PASS | _write_sudoers_with_password |
## DoD
- [x] pytest test_rsync_elevation + test_nexus_files_sudoers
- [x] changelog 2026-06-11-rsync-push-sudo-elevation.md
- [x] 批量脚本 batch_patch_files_sudoers.sh
## 验证
```bash
pytest tests/test_rsync_elevation.py tests/test_nexus_files_sudoers.py -q
bash scripts/batch_patch_files_sudoers.sh --name 温胜夜 --dry-run
```
@@ -0,0 +1,46 @@
# 审计 — 服务器搜索历史 MySQL 持久化
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `server/domain/models/__init__.py` | 新增 `AdminUiPreference` |
| `server/infrastructure/database/admin_ui_preference_repo.py` | 读写偏好 JSON |
| `server/infrastructure/database/migrations.py` | 建表 `admin_ui_preferences` |
| `server/utils/server_search_history.py` | 去重/上限 12 条 |
| `server/api/servers.py` | `GET/PUT /api/servers/search-history` |
| `server/api/schemas.py` | `ServerSearchHistoryUpdate` |
| `frontend/src/composables/useServerSearchHistory.ts` | API + localStorage 一次性迁移 |
| `frontend/src/pages/ServersPage.vue` | combobox 搜索历史 |
| `tests/test_server_search_history.py` | 单元 + API 往返 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 路由在 `/{id}` 之前注册 | PASS — `search-history` 静态路径优先 |
| H2 | 仅当前管理员可读写 | PASS — `get_current_admin` + `admin_id` |
| H3 | 无静默吞错 | PASS — API 异常向上抛出 |
| H4 | 无跨层直连 SQL | PASS — 经 `admin_ui_preference_repo` |
| H5 | 输入校验 | PASS — Pydantic + 归一化 strip/上限 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | `servers.py` 路由顺序 |
| H2 | PASS | repo 按 `admin_id` + `context` |
| H3 | PASS | 无 bare except |
| H4 | PASS | 分层合规 |
| H5 | PASS | `server_search_history.py` |
## DoD
- [x] `pytest tests/test_server_search_history.py` 4 passed
- [x] `npm run type-check` 通过
- [x] changelog `2026-06-11-servers-search-history.md`
- [x] migration 启动时建表
## 验证
Servers 页搜索 → 刷新 → 历史仍在;换浏览器同账号 → GET 返回相同 `history`/`last`
@@ -0,0 +1,36 @@
# 审计 — Servers 搜索手动触发与 null 修复
## 范围(文件清单)
| 文件 | 变更 |
|------|------|
| `frontend/src/composables/useServerSearchHistory.ts` | 仅加载历史、不恢复 lastrecord null 安全 |
| `frontend/src/composables/useServerPagination.ts` | search null 安全 trim |
| `frontend/src/pages/ServersPage.vue` | searchDraft + 回车/下拉/清空才搜索 |
| `server/utils/validation_errors_zh.py` | 422 剥离不可序列化 ctx |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 进页不自动过滤 | PASS — refresh 重置 search |
| H2 | 无静默吞错 | PASS — record/load 失败非阻塞 |
| H3 | 422 可 JSON 序列化 | PASS — ctx 不写入响应 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | loadHistoryOnly 不写 search |
| H2 | PASS | try/catch 保留 |
| H3 | PASS | translate_validation_errors |
## DoD
- [x] npm run type-check
- [x] changelog 2026-06-11-servers-search-manual-trigger.md
- [x] 进页全量列表;手动搜索才请求
## 验证
Servers 页进入 → 无搜索 chip;下拉选历史或回车 → 过滤;清空 → 全量。
@@ -0,0 +1,20 @@
# 2026-06-10 — RDP 画布挂载与前端同步修复
## 摘要
修复 3389 页一直「正在连接」:Guacamole 画布需在 `connect()` 前挂入 DOM;连接层用独立 `canvasHost` + 遮罩,避免 Vue 重绘抹掉画布;生产同步 `web/app` 进容器。
## 动机
- 官方 Guacamole 用法:先 `appendChild(display.getElement())``client.connect()`
- 原实现等 `STATE_CONNECTED` 才挂载,sync/flush 可能无法完成,客户端停在 WAITING
- Docker 镜像内前端为旧 vite 缓存,未执行 `sync_webapp_to_container`
## 涉及文件
- `frontend/src/composables/rdp/useRdpSession.ts`
- `frontend/src/pages/RdpPage.vue`
## 验证
生产 `sync_webapp_to_container.sh` 后,3389 连接应进入「已连接」并显示桌面。
@@ -0,0 +1,48 @@
# 2026-06-10 — 移除浏览器 RDP 功能
## 摘要
按产品决定,完全移除 Nexus 内浏览器远程桌面(3389 / guacd / Guacamole)及相关 API、前端页、探针与 Docker 侧车。
## 动机
RDP 浏览器方案投入高、目标机黑屏等问题无法在平台侧根治;不再维护该功能。
## 删除范围
### 后端
- `server/api/rdp.py``server/api/rdp_hosts.py`
- `server/infrastructure/guacamole/`(协议、WS 隧道)
- `server/utils/rdp_config.py``server/infrastructure/database/rdp_host_repo.py`
- `RdpHost` 模型;迁移 `DROP TABLE IF EXISTS rdp_hosts`
- `server/config.py``GUACD_*`
- `servers` API`/rdp-connect``rdp_*` 字段读写
### 前端
- `RdpPage.vue`、侧栏「3389远程」、路由 `/rdp`
- `composables/rdp/*``components/rdp/*``guacamole.d.ts`
- 依赖 `guacamole-common-js`
### 运维 / 测试
- `docker-compose.yml``docker/docker-compose.prod.yml``guacd` 服务与环境变量
- `scripts/rdp_*.py``scripts/guacd_*.py`
- `tests/test_rdp_*.py``tests/test_guacamole_protocol.py`
- E2E `rdp-connect.spec.mjs`
## 迁移 / 重启
- 启动时自动 `DROP TABLE rdp_hosts`(若存在)
- 生产需重新 `docker compose up` 以停掉 guacd 容器并更新 nexus 镜像/前端
- 可从 `.env` 删除 `NEXUS_GUACD_HOST` / `NEXUS_GUACD_PORT`(可选)
## 验证
```bash
pytest tests/ -q --ignore=tests/integration
cd frontend && npm run type-check && npx vite build
```
侧栏不应再出现「3389远程」;`/app/#/rdp` 应 404 或回仪表盘(无路由)。
@@ -0,0 +1,9 @@
# 2026-06-10 — RDP guacd 后台读取队列
## 摘要
WebSocket 中继改为独立 guacd 读取任务 + 队列,避免 cancel 读任务丢 TCP 数据;前端隧道 OPEN 后发送尺寸。
## 验证
生产容器内 ping 回显 + 20s 内收到 sync。
@@ -0,0 +1,20 @@
# 2026-06-10 — RDP WebSocket 单循环中继(修复 Server timeout
## 摘要
修复 Guacamole 客户端报 `Server timeout.`:双协程并发 `send/receive` 违反 Starlette WebSocket 约束,隧道 ping 无法回显;改为单循环中继,并按指令拆分转发。
## 动机
客户端 `receiveTimeout` 默认 15s,期间必须收到服务端数据(含 ping 回显)。并发 `ws_to_guacd` / `guacd_to_ws` 会导致 ping 丢失。
## 涉及文件
- `server/infrastructure/guacamole/ws_tunnel.py`
- `server/infrastructure/guacamole/protocol.py``iter_raw_instructions`
- `frontend/src/composables/rdp/useRdpSession.ts``receiveTimeout=90s`
- `tests/test_guacamole_protocol.py`
## 验证
生产:3389 连接不再 15s 超时;guacd 日志有 RDPDR 连接。
@@ -0,0 +1,38 @@
# 2026-06-11 内置 Web 浏览器
## 摘要
新增侧栏「浏览器」页:地址栏 + iframe 预览 http(s) 站点;服务器列表增加「站点」快捷入口(由 domain 推导 `https://{host}`)。
## 动机
运维需在 Nexus 内快速查看子机网站,无需切换外部浏览器;与终端、文件管理形成并列运维入口。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `docs/design/specs/2026-06-11-embedded-browser-design.md` | 设计说明 |
| `frontend/src/pages/BrowserPage.vue` | 浏览器页 |
| `frontend/src/composables/useEmbeddedBrowser.ts` | 导航与 session 历史 |
| `frontend/src/utils/browserUrl.ts` | URL 校验与域名推导 |
| `frontend/src/router/index.ts` | 路由 `/browser` |
| `frontend/src/App.vue` | 侧栏入口与全屏布局 |
| `frontend/src/pages/ServersPage.vue` | 「站点」按钮 |
## 限制
- 部分站点 `X-Frame-Options` 禁止嵌入 → 使用「新标签打开」
- 首版无 HTTP 反向代理,不解决所有站点嵌入问题
## 迁移 / 重启
- 仅前端构建部署,无需后端重启或数据库迁移
## 验证
```bash
cd frontend && npm run type-check && npx vite build
```
浏览器:`#/browser` 输入 URL;服务器列表点「站点」应打开对应域名。
@@ -0,0 +1,32 @@
# Changelog — 修复文件管理改权限不生效
**日期**2026-06-11
## 摘要
浏览目录 ETag 未包含权限/属主,chmod 后仍可能 304 返回旧列表;对话框默认附带 chown 导致 `chmod && chown` 整段失败。已拆分命令、仅在有变更时提交属主/属组,并扩展 ETag 指纹。
## 动机
用户反馈文件管理「改权限不生效」:接口可能成功但列表权限不变,或仅改 mode 时因 chown 失败而整体失败。
## 涉及文件
- `server/application/services/files_browse_service.py` — ETag 含 permissions/owner/group
- `server/api/sync_v2.py` — chmod/chown 分步执行
- `frontend/src/components/FilePermissionDialog.vue` — 仅变更时提交 owner/group
- `frontend/src/stores/files.ts` — 刷新后不再复用过期 etag
- `tests/test_files_browse_etag.py`
## 迁移 / 重启
- 需部署后端 + 前端
## 验证
```bash
pytest tests/test_files_browse_etag.py tests/test_file_permissions.py -q
cd frontend && npm run type-check
```
文件页修改权限 → 列表权限位立即更新;仅改八进制时不触发 chown。
@@ -0,0 +1,39 @@
# 2026-06-11 文件管理上传后自动 www 权限
## 摘要
文件管理上传完成后,自动对远端文件执行与推送相同的 `chown www:www` 与文件 `chmod`(默认 `755`,来自 `NEXUS_RSYNC_PUSH_CHOWN` / `NEXUS_RSYNC_PUSH_CHMOD`)。
## 动机
上传文件默认属主常为 SSH 登录用户(如 root),与站点运行用户 `www` 不一致,导致 Web 无法读写。推送已用 rsync 权限参数统一属主,上传路径此前缺失同类修正。
## 涉及文件
- `server/utils/files_upload_permissions.py` — 解析 push 配置并 SSH chown/chmod
- `server/api/sync_v2.py``_sftp_upload_one` 写入后调用 fixup,响应与审计带 `permission_fixup`
- `frontend/src/composables/files/useFilesActions.ts` — 权限修正失败时 warning 提示
- `tests/test_files_upload_permissions.py` — 单元测试
## 配置
与推送共用环境变量(默认已启用):
- `NEXUS_RSYNC_PUSH_CHOWN=www:www`
- `NEXUS_RSYNC_PUSH_CHMOD=D755,F755`(上传取 `F755` → 文件 `755`
置空两者则跳过上传后权限修正。
## 迁移 / 重启
- 无需数据库迁移
- 需重启 API 容器使后端生效;前端需重新 build 部署
## 验证
```bash
pytest tests/test_files_upload_permissions.py -q
bash scripts/local_verify.sh
```
浏览器:文件管理上传 → `ls -la` 应显示 `www www` 及预期 mode;若 sudo 白名单不足,界面 warning 且上传仍成功。
@@ -0,0 +1,48 @@
# 2026-06-11 全局浮动浏览器与记录持久化
## 摘要
内置浏览器改为 **全局浮动面板**:可拖动/缩放、最小化到**右下角**、**左下角**常驻按钮随时展开;切换侧栏页面时保持活动。浏览记录、标签、窗口位置同步至 MySQL(`admin_ui_preferences`)。
## 动机
用户需要在运维任意页面内预览站点,最小化后不占全屏,且换页不丢失;历史记录需跨浏览器/清缓存保留。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/utils/browser_ui_state.py` | 状态解析与 visit 记录 |
| `server/api/browser.py` | `GET/PUT /api/browser/state` |
| `server/main.py` | 注册路由 |
| `frontend/src/composables/useGlobalBrowser.ts` | 全局单例状态 |
| `frontend/src/components/GlobalBrowserPanel.vue` | 浮动 UI |
| `frontend/src/pages/BrowserPage.vue` | 打开全局浏览器并返回 |
| `frontend/src/App.vue` | 挂载全局组件 |
| `frontend/src/pages/ServersPage.vue` | 「站点」直接打开浮动窗 |
| `tests/test_browser_ui_state.py` | 单测 |
## 迁移 / 重启
- 无需新表(复用 `admin_ui_preferences`
- 需 API 重启 + 前端 build
## 验证
```bash
pytest tests/test_browser_ui_state.py -q
cd frontend && npm run type-check
```
左下角地球按钮打开;最小化后为可拖动浮动条(默认右下角);历史按钮可见记录;换账号后记录隔离。
## 2026-06-11 更新 — 不可关闭、最小化可拖拽
- 移除「关闭浏览器」按钮;仅 **最小化****重启**(重启保留浏览历史)
- 最小化后为全局 **可拖动浮动条**(双击展开),位置持久化 `minimized_rect`
- 仅剩一个标签时不可关闭该标签
- 修复最小化条无法拖动:`useFloatingPanel` 最小尺寸改为可配置(迷你条 200×44),专用拖动手柄
## 说明
iframe 在用户当前的 Chrome/Firefox 等引擎中渲染,非独立安装 Firefox;禁止嵌入的站点请用「新标签打开」。
@@ -0,0 +1,40 @@
# Changelog — 登录 IP 白名单订阅刷新多 Worker 同步与保存竞态修复
**日期**2026-06-11
## 摘要
修复设置页保存订阅 URL 后节点 IP 不立即显示、以及多 uvicorn worker 下各进程白名单内存不一致的问题。保存时同步等待订阅拉取完成并返回新 IP;刷新/保存后通过 Redis `nexus:settings` 广播 `login_*` 键。
## 动机
- 保存后立即 `GET /ip-allowlist` 与异步 `_do_refresh` 竞态,UI 常显示旧/空节点。
- 与 2026-06-08 Telegram 开关同类:仅处理请求的 worker 热更新内存,登录校验读各 worker 本地 `LOGIN_SUBSCRIPTION_IPS`,导致订阅更新后登录时好时坏。
## 涉及文件
- `server/infrastructure/settings_broadcast.py` — 新增 `publish_setting_changes`
- `server/background/ip_allowlist_refresh.py``RefreshResult``do_refresh`、刷新后广播
- `server/api/settings.py` — 保存/切换/手动 IP 路径 await 刷新并广播;保存响应含 `subscription_ips` / `last_refresh`
- `frontend/src/types/api.ts``IpAllowlistSaveResponse`
- `frontend/src/pages/SettingsPage.vue` — 保存后用响应更新节点 chip
- `tests/test_ip_allowlist_sync.py`
## 行为变更
- `POST /settings/ip-allowlist`:有订阅 URL 时**同步**拉取订阅后再返回;响应增加 `subscription_ips``last_refresh``refresh_ok``refresh_error`
- 白名单相关 settings 变更(含 2h 定时刷新)广播至全部 worker。
## 迁移 / 重启
- 无 DB 迁移。
- 需重启/部署后端以加载新逻辑(settings subscriber 已存在,无需额外配置)。
## 验证
```bash
pytest tests/test_ip_allowlist_sync.py tests/test_notify_toggle_sync.py -q
cd frontend && npm run type-check
```
设置页:填订阅 URL → 解析预览 → 保存 → 节点 chip 与「上次刷新」立即更新;多 worker 下任意 worker 处理登录均使用同一 IP 列表。
@@ -0,0 +1,43 @@
# Changelog — 2026-06-11 rsync 推送非 root 自动 sudo 提权
## 摘要
文件推送(rsync)与文件管理对齐:**非 root SSH 用户**在默认 `files_elevation=auto_sudo` 下,远程接收端自动使用 `sudo -n rsync`,不再裸连导致宝塔 `www:www` 站点目录 Permission denied。
## 动机
- 用户反馈:温胜夜两台子机推送失败,诊断 `touch` 权限被拒绝
- 产品设计已定:除 root 外默认 `auto_sudo` 自动提权;此前 rsync 推送未接入该策略
## 变更
| 文件 | 说明 |
|------|------|
| `server/utils/rsync_elevation.py` | 非 root 且非 `off``--rsync-path=sudo -n rsync` |
| `server/application/services/sync_engine_v2.py` | `_rsync_push` 接入提权;日志含 `rsync=sudo` |
| `server/api/sync_v2.py` | 诊断写入测试走 `exec_ssh_command_with_fallback` |
| `server/api/servers.py` | `setup-files-sudo` 模板增加 rsync |
| `docs/deploy/nexus-files-sudoers.example` | 增加 rsync |
| `server/infrastructure/ssh/files_capability.py` | 白名单探测含 rsync,提示推送 |
| `scripts/batch_patch_files_sudoers.py` | 批量补全 sudoers(含 rsync |
| `scripts/batch_patch_files_sudoers.sh` | 包装入口 |
| `tests/test_nexus_files_sudoers.py` | 模板单测 |
## 迁移 / 重启
- 需重启 API / 重建 Docker 镜像
- **目标机**:已有 `nexus-files` sudoers 须补 `/usr/bin/rsync`(或重新点「一键配置 sudo」)
- 无 DB 迁移
## 验证
```bash
pytest tests/test_rsync_elevation.py tests/test_nexus_files_sudoers.py tests/test_rsync_push_permissions.py -q
bash scripts/batch_patch_files_sudoers.sh --name 温胜夜
```
部署后:非 root 服务器推送 → `sync_logs.push_permission``rsync=sudo`;诊断写入 ✓(或 `path_elevated=true`)。
## 回滚
Revert 上述文件;推送恢复为仅 root 可写 www 目录(或手动 chmod)。
@@ -0,0 +1,44 @@
# Changelog — 服务器列表搜索记录存数据库
**日期**2026-06-11
## 摘要
服务器页搜索历史与上次搜索词改为按管理员存入 MySQL `admin_ui_preferences`,换浏览器/清缓存仍可恢复;首次加载自动迁移旧版 localStorage。
## 动机
用户要求搜索记录持久化到数据库,而非仅本机 localStorage。
## 涉及文件
- `server/domain/models/__init__.py``AdminUiPreference`
- `server/infrastructure/database/admin_ui_preference_repo.py`
- `server/infrastructure/database/migrations.py` — 建表
- `server/utils/server_search_history.py` — 归一化/合并规则
- `server/api/servers.py``GET/PUT /api/servers/search-history`
- `server/api/schemas.py``ServerSearchHistoryUpdate`
- `frontend/src/composables/useServerSearchHistory.ts`
- `frontend/src/pages/ServersPage.vue`
- `tests/test_server_search_history.py`
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/servers/search-history` | `{ history: string[], last: string \| null }` |
| PUT | `/api/servers/search-history` | `{ query }` 记录一条,或 `{ history, last? }` 整表替换 |
## 迁移 / 重启
- 启动时 schema migration 自动建表 `admin_ui_preferences`
- 需重启/部署后端;前端需构建部署
## 验证
```bash
pytest tests/test_server_search_history.py -q
cd frontend && npm run type-check
```
服务器页搜索 → 换浏览器同账号登录 → 历史与上次搜索仍在。
@@ -0,0 +1,28 @@
# Changelog — Servers 搜索改为手动触发
**日期**2026-06-11
## 摘要
进入 Servers 页默认展示全量列表,不再自动恢复上次搜索;搜索框仅在下拉选历史、回车或清空时生效,输入过程中不触发列表请求。
## 动机
自动恢复 `last` 搜索导致进页即过滤;`v-combobox` 每次输入都 debounce 请求,交互混乱。
## 涉及文件
- `frontend/src/composables/useServerSearchHistory.ts`
- `frontend/src/pages/ServersPage.vue`
## 迁移 / 重启
- 仅前端构建部署
## 验证
```bash
cd frontend && npm run type-check
```
进 Servers → 全量列表;下拉选历史 / 回车 → 才搜索;清空 → 恢复全量。
@@ -0,0 +1,32 @@
# Changelog — 修复 Servers 搜索 combobox null 导致列表加载失败
**日期**2026-06-11
## 摘要
`v-combobox` 清空时 `search``null``trim()` 抛错触发「加载服务器列表失败」;同时 `{ query: null }` 使 422 响应因 `ctx``ValueError` 无法 JSON 序列化而变成 500。
## 动机
生产日志:`PUT /api/servers/search-history` 输入 `query: null` → 422 处理崩溃;前端 `search.value.trim()` 在 null 时异常。
## 涉及文件
- `frontend/src/composables/useServerPagination.ts`
- `frontend/src/composables/useServerSearchHistory.ts`
- `frontend/src/pages/ServersPage.vue`
- `server/utils/validation_errors_zh.py`
- `tests/test_validation_errors_zh.py`
## 迁移 / 重启
- 需部署前后端;无 DB 变更
## 验证
```bash
pytest tests/test_validation_errors_zh.py tests/test_server_search_history.py -q
cd frontend && npm run type-check
```
Servers 页清空搜索框 → 列表正常加载,无错误 toast。
@@ -0,0 +1,37 @@
# Changelog — 上传/推送体积上限统一 500MB
**日期**2026-06-11
## 摘要
将推送/文件上传的 Nginx `client_max_body_size` 与后端 `MAX_UPLOAD_FILE_SIZE`、下载上限统一为 **500MB**,修复生产 OpenResty 全局 50MB 导致 80MB 推送失败的问题。
## 动机
生产 1Panel OpenResty 全局 `client_max_body_size 50m`,站点未覆盖;仓库模板为 100m,与后端 ZIP 500MB 不一致。用户 80MB 上传在 Nginx 层 413 被拒。
## 涉及文件
- `server/api/sync_v2.py``MAX_UPLOAD_FILE_SIZE`、下载 `MAX_DOWNLOAD_SIZE` → 500MB
- `deploy/nginx_http.conf``deploy/nginx_https.conf``deploy/install.sh`
- `deploy/1panel/openresty-nexus.conf.example` — 500m + 600s 上传超时
- `docs/install-guide.md``docs/design/plans/2026-06-04-1panel-docker-production.md`
## 生产变更
- OpenResty 全局 `nginx.conf``50m``500m`
- `api.synaglobal.vip` 站点 `proxy/root.conf`:增加 `client_max_body_size 500m` 与 proxy/body 600s 超时
## 迁移 / 重启
- 无 DB 迁移;重载 OpenResty/Nginx 后生效
- 后端需部署以应用 `sync_v2.py` 单文件 500MB 校验
## 验证
```bash
pytest tests/test_sync_upload_staging.py -q
nginx -t && openresty -s reload # 生产
```
推送页上传 ≤500MB 单文件/ZIP/批次应通过;>500MB 返回明确 400/413。
+2 -1
View File
@@ -22,4 +22,5 @@ deploy ALL=(ALL) NOPASSWD: \
/bin/tar, /usr/bin/tar, \
/usr/bin/unzip, /usr/bin/zip, \
/usr/bin/tee, /bin/cat, /usr/bin/cat, \
/usr/bin/touch, /bin/touch
/usr/bin/touch, /bin/touch, \
/usr/bin/rsync, /bin/rsync
@@ -223,7 +223,7 @@ docker compose -f docker/docker-compose.prod.yml exec mysql \
| `/ws/` | WebSocket`Upgrade` + `Connection upgrade`,超时 3600s |
| `/health` | 反代(或仅内网探测) |
| `/app/` | 反代(容器内 StaticFiles |
| `client_max_body_size` | `100m`(上传/推送) |
| `client_max_body_size` | `500m`(上传/推送) |
**WS 日志**:勿记录 query 中的 JWT(示例配置已用 `$uri``$args`)。
@@ -0,0 +1,35 @@
# 实施说明 — rsync 推送 sudo 提权
## 背景
非 root SSH 用户推送至宝塔 `www:www` 站点目录时,rsync 接收端无写权限;文件管理的 `files_elevation` 此前未作用于 rsync 推送。
## 方案
与文件管理一致:**root 不提权****非 root** 且 `files_elevation``off`(默认 `auto_sudo`)→ rsync 附加 `--rsync-path="sudo -n rsync"`。仅显式 `off` 禁用。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/utils/rsync_elevation.py` | 新增 |
| `server/application/services/sync_engine_v2.py` | `_rsync_push` 提权与重试 |
| `server/api/sync_v2.py` | diagnose 写入测试 sudo 回退 |
| `server/api/servers.py` | setup-files-sudo 模板加 rsync |
| `docs/deploy/nexus-files-sudoers.example` | 加 rsync |
| `server/infrastructure/ssh/files_capability.py` | 白名单探测含 rsync |
| `tests/test_rsync_elevation.py` | 单测 |
## 目标机要求
`/etc/sudoers.d/nexus-files` 须含 `NOPASSWD: /usr/bin/rsync`(或 `/bin/rsync`)。可通过服务器页「配置 sudo」或手动安装示例文件。
## 验证
```bash
pytest tests/test_rsync_elevation.py tests/test_rsync_push_permissions.py -q
```
## 回滚
Revert 上述文件;推送恢复为仅 root SSH 可写站点目录。
@@ -0,0 +1,34 @@
# 内置 Web 浏览器 — 设计说明
## 背景与目标
运维需在 Nexus 内快速预览子机站点(域名),不必切换外部浏览器;与终端、文件管理并列的运维入口。
## 方案对比
| 方案 | 优点 | 缺点 |
|------|------|------|
| **iframe 嵌入** | 无后端、实现快、无 SSRF | 部分站点 `X-Frame-Options` 禁止嵌入 |
| HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie |
| 仅外链新标签 | 零风险 | 非「内置」 |
**选定**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
## 接口与路由
- 前端 `#/browser?url=https://example.com`
- 服务器列表「站点」→ 带 `url`(由 `domain` 推导 `https://{host}`,不含 SSH 端口)
## 安全
- 仅允许 `http:` / `https:`;拒绝 `javascript:``data:`、凭据 URL
- iframe `sandbox``allow-scripts allow-same-origin allow-forms allow-popups allow-downloads`
- 无后端 fetch,不引入 SSRF
- 不记录浏览审计(被动预览,非 CUD)
## 验收
- [ ] 侧栏「浏览器」进入全屏 iframe 页
- [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开
- [ ] 服务器列表「站点」跳转并加载域名
- [ ] 非法 URL 拒绝并提示
+1 -1
View File
@@ -268,7 +268,7 @@ server {
return 404;
}
client_max_body_size 100m;
client_max_body_size 500m;
access_log /var/log/nginx/nexus_access.log;
error_log /var/log/nginx/nexus_error.log;
@@ -0,0 +1,35 @@
# 温胜夜推送提权 — 生产验证报告
**日期**: 2026-06-11
**部署版本**: `84b388fa` fix(sync): rsync 推送非 root 自动 sudo 提权
## 背景
推送失败:`touch ... m.wenshengye.com ... Permission denied`SSH 用户 `ubuntu`,非 root)。
## 处理
1. 部署 rsync `--rsync-path=sudo -n rsync`(非 root + 默认 `auto_sudo`
2. 批量 sudoers 探测:两台 `sudo -n rsync` 已为 `already_ok`
3. 生产探针推送(`scripts/verify_push_elevation.py --name 温胜夜 --push`
## 结果
| ID | 名称 | SSH | 目标路径 | rsync sudo | 写入(sudo) | dry-run | 实推 |
|----|------|-----|----------|------------|------------|---------|------|
| 313 | 武汉市温胜夜科技有限公司 | ubuntu | `/www/wwwroot/wenshengye.com` | ✓ | ✓ | exit 0 | exit 0, elevated |
| 314 | 子-武汉市温胜夜科技有限公司 | ubuntu | `/www/wwwroot/m.wenshengye.com` | ✓ | ✓ | exit 0 | exit 0, elevated |
**结论**: 2/2 通过。通道已恢复。
## 用户操作
原批次源目录 `/tmp/nexus_upload_dbc7758062ff` 为临时上传目录,可能已被清理,请在推送页**重新上传 ZIP/文件**后再推一次。
## 复验命令(生产容器内)
```bash
cd /opt/nexus
sudo docker cp scripts/verify_push_elevation.py nexus-prod-nexus-1:/app/scripts/
sudo docker exec -w /app nexus-prod-nexus-1 python scripts/verify_push_elevation.py --name 温胜夜 --push
```
+34
View File
@@ -23,8 +23,42 @@ const SIDEBAR_LINKS = {
'/audit': '审计日志',
}
const E2E_ACCESS_TOKEN = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
/** Mock refresh + profile so Pinia bootstraps with a pre-minted JWT (bypass login IP allowlist). */
export async function loginWithAccessToken(page, accessToken = E2E_ACCESS_TOKEN) {
if (!accessToken) {
throw new Error('Set NEXUS_E2E_ACCESS_TOKEN for token-based E2E login')
}
await page.route('**/api/auth/refresh', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ access_token: accessToken }),
})
})
await page.route('**/api/auth/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 1,
username: USER,
totp_enabled: false,
created_at: '2020-01-01T00:00:00Z',
}),
})
})
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
await expect(page.getByRole('button', { name: USER })).toBeVisible({ timeout: 15000 })
}
/** Restore session from worker refresh cookie (avoids parallel UI logins / lockout). */
export async function login(page) {
if (E2E_ACCESS_TOKEN) {
return loginWithAccessToken(page, E2E_ACCESS_TOKEN)
}
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
await expect(page.getByRole('button', { name: 'admin' })).toBeVisible({ timeout: 15000 })
-6
View File
@@ -14,7 +14,6 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"echarts": "^6.1.0",
"guacamole-common-js": "^1.5.0",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4",
"vue": "^3.5.30",
@@ -1344,11 +1343,6 @@
"node": ">= 6"
}
},
"node_modules/guacamole-common-js": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz",
"integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg=="
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-1
View File
@@ -18,7 +18,6 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"echarts": "^6.1.0",
"guacamole-common-js": "^1.5.0",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4",
"vue": "^3.5.30",
+7 -3
View File
@@ -223,6 +223,8 @@
{{ snackbar.text }}
</v-snackbar>
<GlobalBrowserPanel v-if="auth.isLoggedIn" :show-launcher="auth.isLoggedIn" />
</v-app>
</template>
@@ -247,6 +249,8 @@ import {
} from '@/composables/useScriptSubmitToast'
import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api'
@@ -326,8 +330,8 @@ const { mobile } = useDisplay()
provide('nexusDrawer', drawer)
const mainLayoutClass = computed(() => ({
'nexus-terminal-main': route.path === '/terminal' || route.path === '/rdp',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/rdp' && route.path !== '/login',
'nexus-terminal-main': route.path === '/terminal',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
}))
const scriptExecBarOffset = computed(() => {
@@ -343,7 +347,7 @@ const opsItems = [
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
{ to: '/rdp', title: '3389远程', icon: 'mdi-remote-desktop' },
{ to: '/browser', title: '浏览器', icon: 'mdi-web' },
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
+1 -1
View File
@@ -190,7 +190,7 @@ function filenameFromContentDisposition(header: string | null): string | null {
return plain?.[1] ?? null
}
/** GET binary download (e.g. /servers/{id}/rdp-file). */
/** GET binary download (e.g. exported files). */
export async function downloadGet(path: string): Promise<{ blob: Blob; filename: string }> {
const res = await fetchAuthed(path, { method: 'GET' })
if (res.status === 202) {
@@ -239,6 +239,10 @@ const owner = ref('')
const group = ref('')
const initialMode = ref('')
const initialOwner = ref('')
const initialGroup = ref('')
const capability = ref<FilesCapabilityResult | null>(null)
const capabilityLoading = ref(false)
@@ -288,11 +292,14 @@ watch(
if (!open || !file) return
const fileOwner = file.owner || ''
const fileGroup = file.group || ''
mode.value = file.mode_octal || ''
owner.value = file.owner || ''
group.value = file.group && file.group !== file.owner ? file.group : ''
owner.value = fileOwner
group.value = fileGroup && fileGroup !== fileOwner ? fileGroup : ''
initialMode.value = mode.value
initialOwner.value = fileOwner
initialGroup.value = fileGroup
recursive.value = false
capability.value = null
@@ -427,6 +434,10 @@ function submit() {
const o = owner.value.trim()
const g = group.value.trim()
const rec = isDirectory.value && recursive.value
const ownerChanged = o !== initialOwner.value.trim()
const groupChanged = g !== (initialGroup.value.trim() && initialGroup.value.trim() !== initialOwner.value.trim()
? initialGroup.value.trim()
: '')
if (rec) {
const name = props.file?.name ?? '该目录'
const ok = window.confirm(
@@ -436,8 +447,8 @@ function submit() {
}
emit('apply', {
mode: m,
owner: o || undefined,
group: g || undefined,
owner: ownerChanged || groupChanged ? (o || undefined) : undefined,
group: groupChanged ? (g || undefined) : undefined,
recursive: rec || undefined,
})
}
@@ -0,0 +1,543 @@
<template>
<Teleport to="body">
<div
v-if="panelOpen && tabs.length"
class="global-browser-float"
:class="{ 'global-browser-float--maximized': maximized }"
:style="maximized ? undefined : panelStyle"
>
<v-card border rounded="lg" class="global-browser-panel d-flex flex-column h-100">
<div
class="global-browser-toolbar d-flex align-center flex-shrink-0"
:class="{ 'global-browser-toolbar--draggable': !maximized }"
@mousedown="onToolbarMouseDown"
>
<div
v-if="!maximized"
class="global-browser-drag-strip flex-shrink-0"
title="拖动"
@mousedown="onToolbarMouseDown"
>
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
</div>
<v-tabs
:model-value="activeTabId ?? undefined"
density="compact"
show-arrows
class="global-browser-tabs flex-grow-1 min-w-0"
@update:model-value="onTabSelected"
@mousedown.stop
>
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
<v-btn
v-if="tabs.length > 1"
icon="mdi-close"
size="x-small"
variant="text"
class="ml-1 opacity-60"
density="compact"
@click.stop="closeTab(tab.id)"
/>
</v-tab>
</v-tabs>
<v-btn
size="small"
variant="text"
icon="mdi-plus"
title="新标签"
density="compact"
@mousedown.stop
@click="newEmptyTab"
/>
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" @mousedown.stop />
<v-btn
size="small"
variant="text"
icon="mdi-history"
title="浏览记录"
density="compact"
@mousedown.stop
@click="showHistory = !showHistory"
/>
<v-btn
size="small"
variant="text"
icon="mdi-restart"
title="重启浏览器(保留历史记录)"
density="compact"
@mousedown.stop
@click="onRestart"
/>
<v-btn
size="small"
variant="text"
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
density="compact"
@mousedown.stop
@click="maximized = !maximized"
/>
<v-btn
size="small"
variant="text"
icon="mdi-window-minimize"
title="最小化(可拖动)"
density="compact"
class="mr-1"
@mousedown.stop
@click="minimizePanel"
/>
</div>
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="goBack" />
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="goForward" />
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="refreshActive" />
<v-text-field
v-model="addressDraft"
density="compact"
variant="outlined"
hide-details
placeholder="https://example.com"
prepend-inner-icon="mdi-web"
class="global-browser-url mx-2"
@keydown.enter.prevent="onGo"
/>
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
<v-btn
icon="mdi-open-in-new"
variant="text"
size="small"
:disabled="!activeTab?.url"
@click="openExternal"
/>
</div>
<v-alert
v-if="lastError"
type="warning"
density="compact"
variant="tonal"
class="ma-2 mb-0 flex-shrink-0"
closable
@click:close="lastError = null"
>
{{ lastError }}
</v-alert>
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
<div class="text-caption font-weight-medium px-3 py-2">浏览记录已同步账号</div>
<v-list density="compact" nav class="py-0 global-browser-history-list">
<v-list-item
v-for="(visit, idx) in visits"
:key="`${visit.at}-${idx}`"
:title="visit.title"
:subtitle="visit.url"
@click="openFromHistory(visit.url)"
/>
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
</v-list>
</aside>
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
<div
v-if="!activeTab?.url"
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
>
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
<p class="text-body-2 text-center">输入地址后按回车或点打开</p>
<p class="text-caption mt-2 text-center">
浏览器不可关闭仅可最小化或重启页面在您当前的 Chrome / Firefox 引擎中渲染
</p>
</div>
<template v-else>
<iframe
:key="activeTab.frameKey"
:src="activeTab.url"
class="global-browser-frame flex-grow-1"
title="内置浏览器"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
referrerpolicy="no-referrer-when-downgrade"
/>
<p class="text-caption text-medium-emphasis px-2 py-1 flex-shrink-0 global-browser-hint">
若空白可能禁止 iframe 嵌入 请用新标签打开
</p>
</template>
</div>
</div>
<div
v-if="!maximized"
class="global-browser-resize"
title="拖动调整大小"
@mousedown.stop="onResizeStart"
/>
</v-card>
</div>
<!-- Minimized draggable floating bar (any corner) -->
<div
v-if="minimized && tabs.length"
class="global-browser-mini-float"
:style="miniPanelStyle"
@mousedown="onMiniFloatMouseDown"
>
<v-card border rounded="lg" class="global-browser-mini-panel h-100 d-flex flex-column">
<div
class="global-browser-mini-toolbar d-flex align-center px-2 flex-grow-1 global-browser-toolbar--draggable"
@dblclick.stop="openPanel"
>
<div
class="global-browser-drag-strip global-browser-drag-strip--mini flex-shrink-0"
title="拖动"
@mousedown.stop="onMiniDragStart"
>
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
</div>
<v-icon icon="mdi-web" size="18" class="mr-2 flex-shrink-0" />
<span
class="text-caption text-truncate flex-grow-1 min-w-0 global-browser-mini-title"
@click="onMiniTitleClick"
>
{{ activeTab ? tabLabel(activeTab) : '浏览器' }}
</span>
<v-chip v-if="tabs.length > 1" size="x-small" class="mx-1 flex-shrink-0">{{ tabs.length }}</v-chip>
<v-btn
size="x-small"
variant="text"
icon="mdi-restart"
title="重启浏览器"
@mousedown.stop
@click.stop="onRestart"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-dock-window"
title="展开"
@mousedown.stop
@click.stop="openPanel"
/>
</div>
</v-card>
</div>
<v-btn
v-if="showLauncher"
class="global-browser-launcher"
color="primary"
icon="mdi-web"
size="large"
elevation="4"
title="展开浏览器"
@click="onLauncherClick"
/>
</Teleport>
<v-dialog v-model="showRestartConfirm" max-width="400">
<v-card border>
<v-card-title>重启浏览器</v-card-title>
<v-card-text>将关闭所有标签并打开空白页浏览历史记录会保留</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useFloatingPanel, type FloatRect } from '@/composables/useFloatingPanel'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
defineProps<{ showLauncher: boolean }>()
const showHistory = ref(false)
const showRestartConfirm = ref(false)
const maximized = ref(false)
const {
panelOpen,
minimized,
lastError,
addressDraft,
visits,
tabs,
activeTabId,
activeTab,
panelRect,
minimizedRect,
canGoBack,
canGoForward,
tabLabel,
openPanel,
minimizePanel,
restartBrowser,
navigate,
refreshActive,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
bootstrap,
} = useGlobalBrowser()
function defaultMiniRect(): FloatRect {
const w = 320
const h = 52
return {
x: Math.max(16, window.innerWidth - w - 16),
y: Math.max(16, window.innerHeight - h - 16),
w,
h,
}
}
const panelFallback: FloatRect = { x: 80, y: 72, w: 980, h: 640 }
const { rect, panelStyle, startDrag, startResize, centerOnScreen } = useFloatingPanel(
'nexus_global_browser_rect_v1',
panelFallback,
)
const {
rect: miniRect,
panelStyle: miniPanelStyle,
startDrag: startMiniDrag,
} = useFloatingPanel('nexus_global_browser_mini_rect_v1', defaultMiniRect(), {
minW: 200,
minH: 44,
maxW: 520,
maxH: 56,
})
let miniDragMoved = false
let suppressMiniTitleClick = false
function seedRectFromServer() {
if (panelRect.value) {
rect.value = { ...panelRect.value }
} else {
centerOnScreen()
}
if (minimizedRect.value) {
miniRect.value = { ...minimizedRect.value }
} else {
miniRect.value = defaultMiniRect()
}
}
onMounted(async () => {
await bootstrap()
seedRectFromServer()
})
watch(rect, (value) => {
savePanelRect({ ...value })
}, { deep: true })
watch(miniRect, (value) => {
saveMinimizedRect({ ...value })
}, { deep: true })
function isDragBlockedTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return true
return Boolean(target.closest('button, .v-tab, .v-btn, input, a, .v-field, .v-chip'))
}
function onToolbarMouseDown(e: MouseEvent) {
if (maximized.value) return
if (isDragBlockedTarget(e.target)) return
e.preventDefault()
startDrag(e)
}
function onMiniDragStart(e: MouseEvent) {
if (e.button !== 0) return
e.preventDefault()
miniDragMoved = false
const originX = e.clientX
const originY = e.clientY
const onMove = (ev: MouseEvent) => {
if (Math.abs(ev.clientX - originX) + Math.abs(ev.clientY - originY) > 4) {
miniDragMoved = true
}
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
suppressMiniTitleClick = miniDragMoved
if (miniDragMoved) {
window.setTimeout(() => {
suppressMiniTitleClick = false
}, 0)
}
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
startMiniDrag(e)
}
function onMiniFloatMouseDown(e: MouseEvent) {
if (e.button !== 0) return
if (isDragBlockedTarget(e.target)) return
if ((e.target as HTMLElement).closest('.global-browser-drag-strip')) return
e.preventDefault()
onMiniDragStart(e)
}
function onResizeStart(e: MouseEvent) {
startResize(e)
}
function onTabSelected(id: unknown) {
if (typeof id === 'string') switchTab(id)
}
function onGo() {
navigate(addressDraft.value)
}
function onLauncherClick() {
if (tabs.value.length) {
openPanel()
return
}
newEmptyTab()
}
function onMiniTitleClick() {
if (suppressMiniTitleClick) return
openPanel()
}
function onRestart() {
showRestartConfirm.value = true
}
function confirmRestart() {
showRestartConfirm.value = false
restartBrowser()
}
</script>
<style scoped>
.global-browser-float,
.global-browser-mini-float {
position: fixed;
z-index: 2350;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
touch-action: none;
}
.global-browser-float--maximized {
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
left: 0 !important;
top: 0 !important;
}
.global-browser-panel,
.global-browser-mini-panel {
overflow: hidden;
position: relative;
height: 100%;
background: rgb(var(--v-theme-surface));
}
.global-browser-toolbar,
.global-browser-mini-toolbar {
height: 40px;
min-height: 40px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-mini-toolbar {
height: 100%;
min-height: 0;
border-bottom: none;
}
.global-browser-toolbar--draggable {
cursor: default;
user-select: none;
}
.global-browser-drag-strip {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 40px;
margin-left: 4px;
cursor: move;
touch-action: none;
}
.global-browser-drag-strip--mini {
width: 24px;
height: 100%;
margin-left: 0;
margin-right: 4px;
}
.global-browser-mini-title {
cursor: pointer;
}
.global-browser-nav {
background: rgb(var(--v-theme-surface));
}
.global-browser-url {
flex: 1 1 auto;
min-width: 100px;
}
.global-browser-main {
min-height: 0;
}
.global-browser-history {
width: 280px;
max-width: 40%;
background: rgb(var(--v-theme-surface));
}
.global-browser-history-list {
max-height: 100%;
overflow-y: auto;
}
.global-browser-frame {
width: 100%;
min-height: 0;
border: 0;
background: rgb(var(--v-theme-surface));
}
.global-browser-hint {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.global-browser-resize {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
z-index: 3;
background: linear-gradient(
135deg,
transparent 50%,
rgba(var(--v-theme-on-surface), 0.25) 50%
);
}
.global-browser-launcher {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 2340;
}
</style>
@@ -1,115 +0,0 @@
<template>
<v-dialog :model-value="modelValue" max-width="520" persistent @update:model-value="emit('update:modelValue', $event)">
<v-card border>
<v-card-title>{{ editingId ? '编辑 RDP 主机' : '添加 RDP 主机' }}</v-card-title>
<v-divider />
<v-card-text class="pt-4">
<v-text-field
v-model="localForm.name"
label="名称"
variant="outlined"
density="compact"
:rules="[required('名称')]"
class="mb-2"
/>
<v-text-field
v-model="localForm.host"
label="IP / 域名"
variant="outlined"
density="compact"
placeholder="例:192.168.1.100"
:rules="[required('IP 或域名')]"
class="mb-2"
/>
<v-row dense>
<v-col cols="5">
<v-text-field
v-model.number="localForm.port"
label="端口"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="7">
<v-text-field
v-model="localForm.username"
label="用户名"
variant="outlined"
density="compact"
:rules="[required('用户名')]"
/>
</v-col>
</v-row>
<v-text-field
v-model="localForm.password"
label="密码"
type="password"
variant="outlined"
density="compact"
:placeholder="editingId && passwordSet ? '留空则不修改' : ''"
:hint="editingId && passwordSet ? '已设置密码;留空保持不变' : '连接远程桌面必需'"
persistent-hint
class="mb-2"
/>
<v-textarea
v-model="localForm.description"
label="备注(可选)"
variant="outlined"
density="compact"
rows="2"
auto-grow
/>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" :disabled="saving" @click="emit('update:modelValue', false)">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="saving" @click="onSave">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { required } from '@/utils/validation'
import type { RdpHostFormModel } from '@/composables/rdp/useRdpHostList'
const props = defineProps<{
modelValue: boolean
form: RdpHostFormModel
editingId: number | null
passwordSet: boolean
saving: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [form: RdpHostFormModel]
}>()
const localForm = ref<RdpHostFormModel>({ ...props.form })
watch(
() => props.modelValue,
(open) => {
if (open) localForm.value = { ...props.form }
},
)
watch(
() => props.form,
(f) => {
if (props.modelValue) localForm.value = { ...f }
},
{ deep: true },
)
function onSave() {
const f = localForm.value
if (!f.name.trim() || !f.host.trim() || !f.username.trim()) return
if (!props.editingId && !f.password.trim()) return
emit('save', { ...f })
}
</script>
@@ -491,12 +491,19 @@ export function useFilesActions(options: {
for (const f of uploadFiles.value) form.append('file', f)
form.append('server_id', String(selectedServer.value))
form.append('remote_path', currentPath.value)
await http.upload('/sync/upload', form)
const res = await http.upload<{
permission_fixup?: { applied?: boolean; error?: string | null }
}>('/sync/upload', form)
showUpload.value = false
uploadFiles.value = []
options.invalidateAfterMutation()
await options.browse({ force: true })
snackbar('上传成功')
const pf = res?.permission_fixup
if (pf?.error) {
snackbar(`上传成功,但权限修正失败: ${pf.error}`, 'warning')
} else {
snackbar('上传成功')
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上传失败'
snackbar(msg, 'error')
@@ -1,143 +0,0 @@
import { ref, computed } from 'vue'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError'
export interface RdpHostItem {
id: number
name: string
host: string
port: number
username: string
password_set: boolean
description: string
}
export interface RdpHostFormModel {
name: string
host: string
port: number
username: string
password: string
description: string
}
export function emptyRdpHostForm(): RdpHostFormModel {
return {
name: '',
host: '',
port: 3389,
username: 'Administrator',
password: '',
description: '',
}
}
export function useRdpHostList() {
const snackbar = useSnackbar()
const hosts = ref<RdpHostItem[]>([])
const search = ref('')
const loading = ref(false)
const saving = ref(false)
const filteredHosts = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return hosts.value
return hosts.value.filter(
(h) =>
h.name.toLowerCase().includes(q)
|| h.host.toLowerCase().includes(q)
|| h.username.toLowerCase().includes(q),
)
})
async function loadHosts() {
loading.value = true
try {
const res = await http.get<{ items: RdpHostItem[] }>('/rdp/hosts/')
hosts.value = res.items || []
} catch (e) {
hosts.value = []
snackbar(formatApiError(e, '加载 RDP 主机列表失败'), 'warning')
} finally {
loading.value = false
}
}
async function createHost(form: RdpHostFormModel): Promise<RdpHostItem | null> {
saving.value = true
try {
const created = await http.post<RdpHostItem>('/rdp/hosts/', {
name: form.name.trim(),
host: form.host.trim(),
port: Number(form.port) || 3389,
username: form.username.trim(),
password: form.password,
description: form.description.trim() || undefined,
})
await loadHosts()
snackbar('已添加 RDP 主机', 'success')
return created
} catch (e) {
snackbar(formatApiError(e, '添加失败'), 'error')
return null
} finally {
saving.value = false
}
}
async function updateHost(id: number, form: RdpHostFormModel, passwordSet: boolean): Promise<boolean> {
saving.value = true
try {
const body: Record<string, unknown> = {
name: form.name.trim(),
host: form.host.trim(),
port: Number(form.port) || 3389,
username: form.username.trim(),
description: form.description.trim() || '',
}
if (form.password.trim()) {
body.password = form.password.trim()
} else if (!passwordSet) {
snackbar('请填写 RDP 密码', 'error')
return false
}
await http.put(`/rdp/hosts/${id}`, body)
await loadHosts()
snackbar('已保存', 'success')
return true
} catch (e) {
snackbar(formatApiError(e, '保存失败'), 'error')
return false
} finally {
saving.value = false
}
}
async function deleteHost(id: number, name: string): Promise<boolean> {
saving.value = true
try {
await http.delete(`/rdp/hosts/${id}`)
await loadHosts()
snackbar(`已删除「${name}`, 'success')
return true
} catch (e) {
snackbar(formatApiError(e, '删除失败'), 'error')
return false
} finally {
saving.value = false
}
}
return {
hosts,
search,
filteredHosts,
loading,
saving,
loadHosts,
createHost,
updateHost,
deleteHost,
}
}
@@ -1,146 +0,0 @@
import { ref, shallowRef, onBeforeUnmount } from 'vue'
import Guacamole from 'guacamole-common-js'
import { http } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { buildWebSocketUrl } from '@/utils/wsUrl'
import { formatApiError } from '@/utils/apiError'
export type RdpSessionStatus = 'idle' | 'loading' | 'connecting' | 'connected' | 'error' | 'disconnected'
export interface RdpConnectInfo {
host_id: number
name: string
hostname: string
port: number
username: string
}
/** Guacamole connect query — credentials stay server-side (rdp_hosts). */
function buildConnectString(info: RdpConnectInfo): string {
const pairs: [string, string][] = [
['hostname', info.hostname],
['port', String(info.port)],
['username', info.username],
['security', 'any'],
['ignore-cert', 'true'],
['disable-wallpaper', 'true'],
['disable-theming', 'true'],
['enable-font-smoothing', 'true'],
]
return pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
}
export function useRdpSession() {
const status = ref<RdpSessionStatus>('idle')
const errorMessage = ref('')
const serverName = ref('')
const displayEl = shallowRef<HTMLElement | null>(null)
let client: InstanceType<typeof Guacamole.Client> | null = null
let tunnel: InstanceType<typeof Guacamole.WebSocketTunnel> | null = null
let resizeObserver: ResizeObserver | null = null
let mountTarget: HTMLElement | null = null
function detachDisplay() {
resizeObserver?.disconnect()
resizeObserver = null
if (mountTarget && displayEl.value?.parentElement === mountTarget) {
mountTarget.innerHTML = ''
}
displayEl.value = null
}
function disconnect() {
detachDisplay()
try {
client?.disconnect()
} catch {
/* ignore */
}
client = null
tunnel = null
if (status.value !== 'error') {
status.value = 'disconnected'
}
}
function sendDisplaySize() {
if (!client || !mountTarget) return
const w = Math.max(640, mountTarget.clientWidth || 1024)
const h = Math.max(480, mountTarget.clientHeight || 768)
client.sendSize(w, h)
}
async function connect(hostId: number, container: HTMLElement) {
disconnect()
mountTarget = container
status.value = 'loading'
errorMessage.value = ''
const auth = useAuthStore()
if (!auth.token) {
status.value = 'error'
errorMessage.value = '未登录'
return
}
let info: RdpConnectInfo
try {
info = await http.get<RdpConnectInfo>(`/rdp/hosts/${hostId}/connect`)
} catch (e) {
status.value = 'error'
errorMessage.value = formatApiError(e, '加载 RDP 连接参数失败')
return
}
serverName.value = info.name
status.value = 'connecting'
// Token in path: Guacamole Client.connect() appends ?hostname=… to tunnel URL.
const wsUrl = buildWebSocketUrl(
`/ws/rdp/hosts/${hostId}/tunnel/${encodeURIComponent(auth.token)}`,
)
tunnel = new Guacamole.WebSocketTunnel(wsUrl)
const rdpClient = new Guacamole.Client(tunnel)
client = rdpClient
rdpClient.onerror = (st: { code?: number; message?: string }) => {
status.value = 'error'
errorMessage.value = st?.message || `RDP 错误 (${st?.code ?? 'unknown'})`
disconnect()
}
rdpClient.onstatechange = (state: number) => {
if (state === Guacamole.Client.STATE_CONNECTED) {
status.value = 'connected'
const el = rdpClient.getDisplay().getElement()
if (el && mountTarget) {
mountTarget.innerHTML = ''
mountTarget.appendChild(el)
displayEl.value = el
sendDisplaySize()
resizeObserver = new ResizeObserver(() => sendDisplaySize())
resizeObserver.observe(mountTarget)
}
} else if (state === Guacamole.Client.STATE_DISCONNECTED) {
if (status.value !== 'error') status.value = 'disconnected'
}
}
const display = rdpClient.getDisplay()
display.onresize = () => sendDisplaySize()
rdpClient.connect(buildConnectString(info))
}
onBeforeUnmount(() => disconnect())
return {
status,
errorMessage,
serverName,
connect,
disconnect,
sendDisplaySize,
}
}
+28 -7
View File
@@ -34,8 +34,28 @@ function saveRect(key: string, rect: FloatRect) {
}
}
export interface FloatingPanelOptions {
/** Minimum width when clamping (default 480) */
minW?: number
/** Minimum height when clamping (default 320) */
minH?: number
/** Maximum width cap (default 1400) */
maxW?: number
/** Maximum height cap (default 900) */
maxH?: number
}
/** 可拖动浮动面板位置与尺寸(持久化到 localStorage */
export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
export function useFloatingPanel(
storageKey: string,
fallback: FloatRect,
options: FloatingPanelOptions = {},
) {
const minW = options.minW ?? 480
const minH = options.minH ?? 320
const maxWCap = options.maxW ?? 1400
const maxHCap = options.maxH ?? 900
const rect = ref<FloatRect>(loadRect(storageKey, fallback))
let dragOrigin = { px: 0, py: 0, rx: 0, ry: 0 }
@@ -51,18 +71,18 @@ export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
function clampToViewport() {
const margin = 8
const maxW = Math.min(window.innerWidth - margin * 2, 1400)
const maxH = Math.min(window.innerHeight - margin * 2, 900)
rect.value.w = Math.max(480, Math.min(rect.value.w, maxW))
rect.value.h = Math.max(320, Math.min(rect.value.h, maxH))
const maxW = Math.min(window.innerWidth - margin * 2, maxWCap)
const maxH = Math.min(window.innerHeight - margin * 2, maxHCap)
rect.value.w = Math.max(minW, Math.min(rect.value.w, maxW))
rect.value.h = Math.max(minH, Math.min(rect.value.h, maxH))
rect.value.x = Math.max(margin, Math.min(rect.value.x, window.innerWidth - rect.value.w - margin))
rect.value.y = Math.max(margin, Math.min(rect.value.y, window.innerHeight - rect.value.h - margin))
}
function onDragMove(e: MouseEvent) {
if (resizing) {
rect.value.w = Math.max(480, resizeOrigin.rw + e.clientX - resizeOrigin.px)
rect.value.h = Math.max(320, resizeOrigin.rh + e.clientY - resizeOrigin.py)
rect.value.w = Math.max(minW, resizeOrigin.rw + e.clientX - resizeOrigin.px)
rect.value.h = Math.max(minH, resizeOrigin.rh + e.clientY - resizeOrigin.py)
clampToViewport()
return
}
@@ -80,6 +100,7 @@ export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
function startDrag(e: MouseEvent) {
if (e.button !== 0) return
e.preventDefault()
dragOrigin = { px: e.clientX, py: e.clientY, rx: rect.value.x, ry: rect.value.y }
window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', onDragEnd)
@@ -0,0 +1,384 @@
/**
* Global floating browser — persists tabs/history to MySQL via /api/browser/state.
* Singleton module state survives route changes.
*/
import { computed, ref } from 'vue'
import { http } from '@/api'
import { normalizeBrowserUrl } from '@/utils/browserUrl'
import type { FloatRect } from '@/composables/useFloatingPanel'
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
const SAVE_DEBOUNCE_MS = 700
export interface BrowserVisit {
url: string
title: string
at: string
}
export interface BrowserTab {
id: string
url: string
title: string
stack: string[]
stackIndex: number
frameKey: number
}
export interface BrowserStateResponse {
url_history: string[]
visits: BrowserVisit[]
tabs: Array<{
id: string
url: string
title?: string
stack?: string[]
stack_index?: number
}>
active_tab_id: string | null
minimized: boolean
rect: FloatRect | null
minimized_rect: FloatRect | null
}
const bootstrapped = ref(false)
const saving = ref(false)
const panelOpen = ref(false)
const minimized = ref(false)
const maximized = ref(false)
const lastError = ref<string | null>(null)
const addressDraft = ref('')
const urlHistory = ref<string[]>([])
const visits = ref<BrowserVisit[]>([])
const tabs = ref<BrowserTab[]>([])
const activeTabId = ref<string | null>(null)
const panelRect = ref<FloatRect | null>(null)
const minimizedRect = ref<FloatRect | null>(null)
let saveTimer: ReturnType<typeof setTimeout> | null = null
function tabLabel(tab: BrowserTab): string {
try {
return tab.title || new URL(tab.url).hostname
} catch {
return tab.title || tab.url
}
}
function newTabId(): string {
return crypto.randomUUID()
}
function serializeTabs(): BrowserStateResponse['tabs'] {
return tabs.value.map((t) => ({
id: t.id,
url: t.url,
title: t.title,
stack: t.stack,
stack_index: t.stackIndex,
}))
}
function applyServerState(data: BrowserStateResponse) {
urlHistory.value = data.url_history || []
visits.value = (data.visits || []).map((v) => ({
url: v.url,
title: v.title || v.url,
at: v.at,
}))
tabs.value = (data.tabs || []).map((t) => ({
id: t.id,
url: t.url,
title: t.title || t.url,
stack: t.stack?.length ? t.stack : [t.url],
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
frameKey: 0,
}))
activeTabId.value = data.active_tab_id
if (!activeTabId.value && tabs.value.length) {
activeTabId.value = tabs.value[0]!.id
}
minimized.value = Boolean(data.minimized)
panelRect.value = data.rect
minimizedRect.value = data.minimized_rect
panelOpen.value = tabs.value.length > 0 && !minimized.value
syncAddressFromActive()
}
function syncAddressFromActive() {
const tab = activeTab.value
addressDraft.value = tab?.url ?? ''
}
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
const canGoBack = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex > 0 : false
})
const canGoForward = computed(() => {
const tab = activeTab.value
return tab ? tab.stackIndex < tab.stack.length - 1 : false
})
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void persistState(extra)
}, SAVE_DEBOUNCE_MS)
}
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
saving.value = true
try {
const body: Record<string, unknown> = {
tabs: serializeTabs(),
active_tab_id: activeTabId.value,
minimized: minimized.value,
rect: panelRect.value,
minimized_rect: minimizedRect.value,
}
if (extra?.recordUrl) {
body.record_url = extra.recordUrl
if (extra.recordTitle) body.record_title = extra.recordTitle
} else {
body.url_history = urlHistory.value
body.visits = visits.value
}
const res = await http.put<BrowserStateResponse>('/browser/state', body)
applyServerState(res)
} catch {
/* keep local state */
} finally {
saving.value = false
}
}
async function migrateLegacyHistory() {
try {
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
if (!urls.length) return
for (const url of [...urls].reverse()) {
const normalized = normalizeBrowserUrl(url)
if (normalized) {
await persistState({ recordUrl: normalized, recordTitle: normalized })
}
}
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
} catch {
/* ignore */
}
}
async function bootstrap() {
if (bootstrapped.value) return
try {
const res = await http.get<BrowserStateResponse>('/browser/state')
applyServerState(res)
if (!visits.value.length && !urlHistory.value.length) {
await migrateLegacyHistory()
}
} catch {
/* offline */
} finally {
bootstrapped.value = true
}
}
function openPanel() {
panelOpen.value = true
minimized.value = false
scheduleSave()
}
function minimizePanel() {
minimized.value = true
panelOpen.value = false
scheduleSave()
}
/** Reset to a single blank tab; browsing history is kept. */
function restartBrowser() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
frameKey: 0,
}
tabs.value = [tab]
activeTabId.value = tab.id
addressDraft.value = ''
lastError.value = null
openPanel()
scheduleSave()
}
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
const normalized = normalizeBrowserUrl(input)
if (!normalized) {
lastError.value = '请输入有效的 http 或 https 地址'
return false
}
lastError.value = null
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
const tab: BrowserTab = {
id: newTabId(),
url: normalized,
title: opts?.title || normalized,
stack: [normalized],
stackIndex: 0,
frameKey: 0,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
} else {
const tab = activeTab.value
const stack = tab.stack.slice(0, tab.stackIndex + 1)
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
tab.url = normalized
if (opts?.title) tab.title = opts.title
tab.stack = stack
tab.stackIndex = stack.length - 1
tab.frameKey += 1
}
addressDraft.value = normalized
openPanel()
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
return true
}
function navigate(input: string) {
return openUrl(input)
}
function refreshActive() {
const tab = activeTab.value
if (!tab) return
tab.frameKey += 1
}
function goBack() {
const tab = activeTab.value
if (!tab || tab.stackIndex <= 0) return
tab.stackIndex -= 1
tab.url = tab.stack[tab.stackIndex]!
tab.frameKey += 1
addressDraft.value = tab.url
scheduleSave()
}
function goForward() {
const tab = activeTab.value
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
tab.stackIndex += 1
tab.url = tab.stack[tab.stackIndex]!
tab.frameKey += 1
addressDraft.value = tab.url
scheduleSave()
}
function openExternal() {
const tab = activeTab.value
if (!tab) return
window.open(tab.url, '_blank', 'noopener,noreferrer')
}
function switchTab(id: string) {
activeTabId.value = id
syncAddressFromActive()
scheduleSave()
}
function closeTab(id: string) {
if (tabs.value.length <= 1) return
const idx = tabs.value.findIndex((t) => t.id === id)
if (idx < 0) return
const next = tabs.value.filter((t) => t.id !== id)
tabs.value = next
if (activeTabId.value === id) {
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
}
syncAddressFromActive()
scheduleSave()
}
function newEmptyTab() {
const tab: BrowserTab = {
id: newTabId(),
url: '',
title: '新标签',
stack: [],
stackIndex: -1,
frameKey: 0,
}
tabs.value = [...tabs.value, tab].slice(-12)
activeTabId.value = tab.id
addressDraft.value = ''
openPanel()
scheduleSave()
}
function openFromHistory(url: string) {
openUrl(url)
}
function savePanelRect(rect: FloatRect) {
panelRect.value = rect
scheduleSave()
}
function saveMinimizedRect(rect: FloatRect) {
minimizedRect.value = rect
scheduleSave()
}
export function useGlobalBrowser() {
return {
bootstrapped,
saving,
panelOpen,
minimized,
maximized,
lastError,
addressDraft,
urlHistory,
visits,
tabs,
activeTabId,
activeTab,
panelRect,
minimizedRect,
isActive,
canGoBack,
canGoForward,
tabLabel,
bootstrap,
openPanel,
minimizePanel,
restartBrowser,
openUrl,
navigate,
refreshActive,
goBack,
goForward,
openExternal,
switchTab,
closeTab,
newEmptyTab,
openFromHistory,
savePanelRect,
saveMinimizedRect,
scheduleSave,
}
}
@@ -2,7 +2,6 @@ const ROUTE_CHUNKS: Record<string, () => Promise<unknown>> = {
'/': () => import('@/pages/DashboardPage.vue'),
'/servers': () => import('@/pages/ServersPage.vue'),
'/terminal': () => import('@/pages/TerminalPage.vue'),
'/rdp': () => import('@/pages/RdpPage.vue'),
'/files': () => import('@/pages/FilesPage.vue'),
'/push': () => import('@/pages/PushPage.vue'),
'/scripts': () => import('@/pages/ScriptsPage.vue'),
@@ -30,7 +30,7 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
const sortBy = ref<TableSortItem[]>([])
function listQueryParams(): Record<string, unknown> {
const q = search.value.trim()
const q = String(search.value ?? '').trim()
// 有搜索词时全局检索:忽略分类/在线/路径分区,避免刚添加的机器搜不到
const applyPathFilter = targetPathUnset !== undefined && !q
return {
@@ -0,0 +1,89 @@
import { ref } from 'vue'
import { http } from '@/api'
const LEGACY_HISTORY = 'nexus_servers_search_history'
const LEGACY_LAST = 'nexus_servers_search_last'
export interface ServerSearchHistoryResponse {
history: string[]
last: string | null
}
function readLegacyLocal(): { history: string[]; last: string } | null {
try {
const raw = localStorage.getItem(LEGACY_HISTORY)
const last = localStorage.getItem(LEGACY_LAST) || ''
if (!raw) return last.trim() ? { history: [], last } : null
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return last.trim() ? { history: [], last } : null
const history = parsed.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
if (!history.length && !last.trim()) return null
return { history, last }
} catch {
return null
}
}
function clearLegacyLocal() {
try {
localStorage.removeItem(LEGACY_HISTORY)
localStorage.removeItem(LEGACY_LAST)
} catch {
// ignore
}
}
/** Servers page search history — persisted per admin in MySQL. */
export function useServerSearchHistory() {
const items = ref<string[]>([])
const bootstrapped = ref(false)
async function load(): Promise<void> {
const res = await http.get<ServerSearchHistoryResponse>('/servers/search-history')
items.value = res.history || []
}
async function migrateLegacyIfEmpty(): Promise<boolean> {
const legacy = readLegacyLocal()
if (!legacy) return false
if (items.value.length > 0) {
clearLegacyLocal()
return false
}
await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
history: legacy.history,
last: legacy.last || legacy.history[0] || '',
})
clearLegacyLocal()
return true
}
/** Load dropdown history only — does not apply any search to the list. */
async function loadHistoryOnly(): Promise<void> {
if (bootstrapped.value) return
try {
await load()
if (!items.value.length && (await migrateLegacyIfEmpty())) {
await load()
}
} catch {
// offline / auth — keep empty history
} finally {
bootstrapped.value = true
}
}
async function record(query: string | null | undefined) {
const normalized = String(query ?? '').trim()
try {
const res = await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
query: normalized,
})
items.value = res.history || []
} catch {
// non-blocking
}
}
return { items, bootstrapped, load, loadHistoryOnly, record }
}
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="pa-6 text-center text-medium-emphasis">正在打开浏览器</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
const route = useRoute()
const router = useRouter()
const browser = useGlobalBrowser()
onMounted(async () => {
await browser.bootstrap()
const url = typeof route.query.url === 'string' ? route.query.url : ''
if (url) {
browser.openUrl(url)
} else if (!browser.tabs.value.length) {
browser.newEmptyTab()
} else {
browser.openPanel()
}
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
? route.query.redirect
: '/'
router.replace(redirect)
})
</script>
-310
View File
@@ -1,310 +0,0 @@
<template>
<v-container fluid class="pa-0 d-flex flex-column rdp-root">
<v-toolbar density="compact" color="surface" border>
<v-toolbar-title class="text-body-1 font-weight-medium">
3389远程
<span v-if="serverName" class="text-medium-emphasis"> {{ serverName }}</span>
</v-toolbar-title>
<v-spacer />
<v-chip
v-if="status !== 'idle'"
size="small"
variant="tonal"
:color="statusColor"
label
class="mr-2"
>
{{ statusLabel }}
</v-chip>
<v-btn
v-if="hostId"
variant="text"
size="small"
class="mr-1"
@click="backToList"
>
返回列表
</v-btn>
<v-btn
variant="tonal"
color="error"
size="small"
:disabled="status === 'idle' || status === 'disconnected'"
@click="disconnectSession"
>
断开
</v-btn>
</v-toolbar>
<div ref="displayHost" class="rdp-display-host flex-grow-1">
<div v-if="showList" class="rdp-list-panel">
<div class="d-flex align-center ga-2 mb-3" style="max-width: 640px; width: 100%">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
placeholder="搜索名称、地址…"
variant="outlined"
density="compact"
hide-details
class="flex-grow-1"
/>
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="openAdd">
添加
</v-btn>
</div>
<v-skeleton-loader v-if="loading" type="list-item@4" max-width="640" width="100%" />
<v-card v-else border rounded="lg" max-width="640" width="100%">
<v-list density="comfortable" nav>
<v-list-item
v-for="h in filteredHosts"
:key="h.id"
:title="h.name"
:subtitle="`${h.host}:${h.port} · ${h.username}`"
rounded="lg"
>
<template #append>
<v-btn
color="primary"
variant="tonal"
size="small"
class="mr-1"
@click="onConnect(h.id)"
>
连接
</v-btn>
<v-btn icon="mdi-pencil" variant="text" size="small" @click="openEdit(h)" />
<v-btn icon="mdi-delete" variant="text" size="small" color="error" @click="confirmDelete(h)" />
</template>
</v-list-item>
<v-list-item v-if="filteredHosts.length === 0" class="text-medium-emphasis">
暂无 RDP 主机点击添加录入 Windows 远程地址
</v-list-item>
</v-list>
</v-card>
</div>
<div v-else-if="status === 'loading' || status === 'connecting'" class="rdp-placeholder">
<v-progress-circular indeterminate color="primary" size="40" />
<p class="text-medium-emphasis mt-3">{{ status === 'loading' ? '加载连接参数…' : '正在连接远程桌面…' }}</p>
</div>
<div v-else-if="status === 'error'" class="rdp-placeholder">
<v-alert type="error" variant="tonal" max-width="480">
{{ errorMessage }}
</v-alert>
<v-btn variant="flat" color="primary" class="mt-3" @click="backToList">返回列表</v-btn>
</div>
</div>
<RdpHostFormDialog
v-model="formVisible"
:form="formModel"
:editing-id="editingId"
:password-set="editingPasswordSet"
:saving="saving"
@save="onSaveHost"
/>
<v-dialog v-model="deleteVisible" max-width="400">
<v-card>
<v-card-title>删除 RDP 主机</v-card-title>
<v-card-text>确定删除{{ deleteTarget?.name }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteVisible = false">取消</v-btn>
<v-btn color="error" variant="flat" :loading="saving" @click="doDelete">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import RdpHostFormDialog from '@/components/rdp/RdpHostFormDialog.vue'
import { useRdpSession } from '@/composables/rdp/useRdpSession'
import {
useRdpHostList,
emptyRdpHostForm,
type RdpHostItem,
type RdpHostFormModel,
} from '@/composables/rdp/useRdpHostList'
defineOptions({ name: 'RdpPage' })
const route = useRoute()
const router = useRouter()
const displayHost = ref<HTMLElement | null>(null)
const { status, errorMessage, serverName, connect, disconnect } = useRdpSession()
const {
search,
filteredHosts,
loading,
saving,
loadHosts,
createHost,
updateHost,
deleteHost,
} = useRdpHostList()
const formVisible = ref(false)
const formModel = ref(emptyRdpHostForm())
const editingId = ref<number | null>(null)
const editingPasswordSet = ref(false)
const deleteVisible = ref(false)
const deleteTarget = ref<RdpHostItem | null>(null)
const hostId = computed(() => {
const raw = route.query.host_id
const n = Number(Array.isArray(raw) ? raw[0] : raw)
return Number.isFinite(n) && n > 0 ? n : null
})
const showList = computed(
() => !hostId.value && (status.value === 'idle' || status.value === 'disconnected'),
)
const statusLabel = computed(() => {
switch (status.value) {
case 'loading': return '加载中'
case 'connecting': return '连接中'
case 'connected': return '已连接'
case 'error': return '失败'
case 'disconnected': return '已断开'
default: return ''
}
})
const statusColor = computed(() => {
switch (status.value) {
case 'connected': return 'success'
case 'error': return 'error'
case 'connecting':
case 'loading': return 'warning'
default: return 'default'
}
})
function onConnect(id: number) {
router.push({ path: '/rdp', query: { host_id: String(id) } })
}
function backToList() {
disconnect()
router.replace({ path: '/rdp' })
}
function disconnectSession() {
disconnect()
if (hostId.value) {
router.replace({ path: '/rdp' })
}
}
function openAdd() {
editingId.value = null
editingPasswordSet.value = false
formModel.value = emptyRdpHostForm()
formVisible.value = true
}
function openEdit(h: RdpHostItem) {
editingId.value = h.id
editingPasswordSet.value = h.password_set
formModel.value = {
name: h.name,
host: h.host,
port: h.port,
username: h.username,
password: '',
description: h.description || '',
}
formVisible.value = true
}
async function onSaveHost(form: RdpHostFormModel) {
if (editingId.value != null) {
const ok = await updateHost(editingId.value, form, editingPasswordSet.value)
if (ok) formVisible.value = false
return
}
const created = await createHost(form)
if (created) {
formVisible.value = false
onConnect(created.id)
}
}
function confirmDelete(h: RdpHostItem) {
deleteTarget.value = h
deleteVisible.value = true
}
async function doDelete() {
const t = deleteTarget.value
if (!t) return
const ok = await deleteHost(t.id, t.name)
if (ok) {
deleteVisible.value = false
deleteTarget.value = null
}
}
async function startIfReady() {
const id = hostId.value
const host = displayHost.value
if (!id || !host) return
await connect(id, host)
}
watch(hostId, (id, prev) => {
if (id === prev) return
if (!id) {
disconnect()
return
}
void startIfReady()
})
onMounted(async () => {
await loadHosts()
if (hostId.value) void startIfReady()
})
</script>
<style scoped>
.rdp-root {
min-height: calc(100dvh - var(--v-layout-top, 64px));
height: calc(100dvh - var(--v-layout-top, 64px));
}
.rdp-display-host {
position: relative;
min-height: 0;
background: #1a1a1a;
overflow: hidden;
}
.rdp-display-host :deep(canvas) {
display: block;
margin: 0 auto;
}
.rdp-list-panel,
.rdp-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: 100%;
min-height: 320px;
padding: 24px 16px;
overflow-y: auto;
}
.rdp-placeholder {
justify-content: center;
}
</style>
+70 -11
View File
@@ -38,16 +38,21 @@
<v-card-title class="d-flex align-center">
服务器列表
<v-spacer />
<v-text-field
v-model="search"
<v-combobox
v-model="searchDraft"
:items="serverSearchHistory"
prepend-inner-icon="mdi-magnify"
label="搜索名称/IP(含未设路径)"
density="compact"
hide-details
rounded
clearable
:auto-select-first="false"
style="max-width: 240px"
variant="outlined"
@update:model-value="onSearch"
placeholder="输入后回车,或从历史选择"
@update:model-value="onSearchComboboxUpdate"
@keydown.enter.prevent="commitSearch"
/>
<v-btn color="primary" variant="flat" class="ml-3" prepend-icon="mdi-plus" size="small" @click="openAddServer">
添加
@@ -240,6 +245,15 @@
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(item)">终端</v-btn>
<v-btn
v-if="item.domain"
variant="text"
size="x-small"
density="compact"
@click.stop="openBrowser(item)"
>
站点
</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="openFiles(item)">文件</v-btn>
<v-btn variant="text" size="x-small" density="compact" :loading="agentDiagnoseLoadingId === item.id" @click.stop="openAgentDiagnose(item)">诊断</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="editServer(item)">编辑</v-btn>
@@ -649,6 +663,7 @@ import { useRouter, useRoute } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { fetchPagePerPage } from '@/utils/paginatedFetch'
@@ -656,6 +671,8 @@ import { isUnsetTargetPath } from '@/utils/serverTargetPath'
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue'
@@ -721,7 +738,6 @@ onMounted(() => {
let offBatchComplete: (() => void) | null = null
onBeforeUnmount(() => {
offBatchComplete?.()
clearTimeout(searchDebounceTimer)
})
defineOptions({ name: 'ServersPage' })
@@ -730,6 +746,7 @@ const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar()
const router = useRouter()
const globalBrowser = useGlobalBrowser()
const route = useRoute()
const showCredentialsDialog = ref(false)
@@ -752,15 +769,46 @@ const {
loadAllServers, listQueryParams,
} = useServerPagination({ targetPathUnset: false })
let searchDebounceTimer: ReturnType<typeof setTimeout>
function onSearch() {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => {
const { items: serverSearchHistory, record: recordServerSearch, loadHistoryOnly: loadServerSearchHistory } =
useServerSearchHistory()
/** Combobox draft; applied filter is `search` from useServerPagination. */
const searchDraft = ref('')
function commitSearch() {
const q = String(searchDraft.value ?? '').trim()
if (q === String(search.value ?? '').trim()) return
search.value = q
searchDraft.value = q
page.value = 1
unsetPathPage.value = 1
void recordServerSearch(q)
void loadServers()
void loadPendingServers(true)
}
function onSearchComboboxUpdate(val: string | null) {
searchDraft.value = val ?? ''
if (val == null || val === '') {
if (!String(search.value ?? '').trim()) return
search.value = ''
page.value = 1
unsetPathPage.value = 1
void recordServerSearch('')
void loadServers()
void loadPendingServers(true)
}, 300)
return
}
const picked = String(val).trim()
if (serverSearchHistory.value.includes(picked)) {
search.value = picked
searchDraft.value = picked
page.value = 1
unsetPathPage.value = 1
void recordServerSearch(picked)
void loadServers()
void loadPendingServers(true)
}
}
const unsetPathServers = ref<ServerApiItem[]>([])
@@ -1037,7 +1085,7 @@ watch(search, () => {
watch(
() => [search.value, total.value, unsetPathTotal.value, pendingServers.value.length, loading.value] as const,
([q, mainTotal, unsetTotal, pendingCount, isLoading]) => {
if (!q.trim() || isLoading) return
if (!String(q ?? '').trim() || isLoading) return
if (mainTotal > 0 || unsetTotal > 0) return
if (pendingCount <= 0) return
void nextTick(() => {
@@ -1093,7 +1141,7 @@ function showFailureDialog(errors: PollErrorItem[], message?: string) {
async function loadPendingServers(silent = false) {
if (!silent) pendingLoading.value = true
try {
const q = search.value.trim()
const q = String(search.value ?? '').trim()
const res = await http.get<{ items: PendingServerItem[]; total: number }>(
'/servers/pending',
q ? { search: q } : undefined,
@@ -1361,6 +1409,14 @@ async function refreshStatCards() {
function openTerminal(item: ServerApiItem) {
router.push({ path: '/terminal', query: { server_id: String(item.id) } })
}
function openBrowser(item: ServerApiItem) {
const url = guessSiteUrlFromDomain(item.domain || '')
if (!url) {
snackbar('该服务器无有效域名', 'warning')
return
}
globalBrowser.openUrl(url, { title: item.name })
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
@@ -1686,6 +1742,9 @@ async function doDelete() {
}
async function refreshServersPage(silent = false) {
await loadServerSearchHistory()
search.value = ''
searchDraft.value = ''
await Promise.all([
loadStats(silent),
loadCategories(silent),
+13 -3
View File
@@ -442,6 +442,7 @@ import type {
OpsPatrolSyncResponse,
SubscriptionParseResponse,
IpAllowlistSaveRequest,
IpAllowlistSaveResponse,
} from '@/types/api'
import { NOTIFY_TOGGLE_ITEMS } from '@/types/api'
import {
@@ -958,10 +959,19 @@ async function saveAllowlist() {
subscription_url: subscriptionUrl.value.trim(),
enabled: ipAllowlistEnabled.value,
}
await http.post('/settings/ip-allowlist', payload)
snackbar('白名单已保存')
const res = await http.post<IpAllowlistSaveResponse>('/settings/ip-allowlist', payload)
subscriptionIps.value = res.subscription_ips || []
allowlistLastRefresh.value = res.last_refresh || null
allowlistTotalCount.value = new Set([
...(res.subscription_ips || []),
...manual_ips,
]).size
if (res.refresh_ok === false && res.refresh_error) {
snackbar(`白名单已保存,但订阅刷新失败:${res.refresh_error}`, 'warning')
} else {
snackbar('白名单已保存')
}
subParseDialog.value = false
await loadAllowlist()
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
} finally {
+1 -1
View File
@@ -5,7 +5,7 @@ const routes = [
{ path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') },
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
{ path: '/rdp', name: 'Rdp', component: () => import('@/pages/RdpPage.vue') },
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
+1 -2
View File
@@ -70,11 +70,10 @@ export const useFilesStore = defineStore('files', () => {
etag?: string,
) {
const key = cacheKey(serverId, path)
const prev = fileCache.get(key)
fileCache.set(key, {
items,
at: Date.now(),
etag: etag ?? prev?.etag,
etag: etag ?? undefined,
})
if (fileCache.size > FILES_CACHE_MAX_ENTRIES) {
const oldest = [...fileCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
+10 -4
View File
@@ -106,6 +106,16 @@ export interface IpAllowlistSaveRequest {
enabled?: boolean
}
export interface IpAllowlistSaveResponse {
success: boolean
manual_count: number
subscription_ips?: string[]
subscription_count?: number
last_refresh?: string | null
refresh_ok?: boolean
refresh_error?: string | null
}
/** File browse response from POST /api/sync/browse */
export interface BrowseResponse {
path?: string
@@ -161,10 +171,6 @@ export interface ServerApiItem {
platform_id: number | null
node_id: number | null
files_elevation?: FilesElevationMode
rdp_enabled?: boolean
rdp_port?: number
rdp_username?: string
rdp_password_set?: boolean
is_online: boolean
last_heartbeat: string | null
agent_version: string | null
-39
View File
@@ -1,39 +0,0 @@
declare module 'guacamole-common-js' {
class Client {
static STATE_IDLE: number
static STATE_CONNECTING: number
static STATE_WAITING: number
static STATE_CONNECTED: number
static STATE_DISCONNECTING: number
static STATE_DISCONNECTED: number
constructor(tunnel: Tunnel)
connect(data?: string): void
disconnect(): void
getDisplay(): Display
onstatechange: ((state: number) => void) | null
onerror: ((status: { code?: number; message?: string }) => void) | null
sendSize(width: number, height: number): void
}
class Display {
getElement(): HTMLElement
onresize: ((width: number, height: number) => void) | null
scale(scale: number): void
}
class Tunnel {
onstatechange: ((state: number) => void) | null
onerror: ((status: { code?: number; message?: string }) => void) | null
}
class WebSocketTunnel extends Tunnel {
constructor(url: string)
}
const Guacamole: {
Client: typeof Client
WebSocketTunnel: typeof WebSocketTunnel
}
export default Guacamole
}
+22
View File
@@ -0,0 +1,22 @@
/** Normalize user input to a safe http(s) URL for embedded iframe navigation. */
export function normalizeBrowserUrl(input: string): string | null {
const trimmed = input.trim()
if (!trimmed) return null
try {
const withProto = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
const parsed = new URL(withProto)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
if (parsed.username || parsed.password) return null
return parsed.href
} catch {
return null
}
}
/** Derive a site URL from server domain (SSH host; strip trailing :port). */
export function guessSiteUrlFromDomain(domain: string): string {
const raw = domain.trim()
if (!raw) return ''
const host = raw.includes(':') ? raw.split(':')[0]!.trim() : raw
return normalizeBrowserUrl(host) ?? `https://${host}`
}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Batch patch /etc/sudoers.d/nexus-files on sub-servers (add rsync for push elevation).
Usage:
python scripts/batch_patch_files_sudoers.py --name 温胜夜
python scripts/batch_patch_files_sudoers.py --non-root-only --limit 50
python scripts/batch_patch_files_sudoers.py --ids 12,34 --dry-run
Requires .env with DATABASE_URL. SSH creds from servers table.
Run from dev machine or on central host: bash scripts/batch_patch_files_sudoers.sh
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _load_dotenv() -> None:
env_path = ROOT / ".env"
if not env_path.is_file():
return
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip("\"'")
if key.startswith("NEXUS_") and key[6:] not in os.environ:
os.environ[key[6:]] = value
elif key and key not in os.environ:
os.environ[key] = value
async def _fetch_servers(
session: Any,
*,
server_id: int | None,
server_ids: list[int] | None,
name_like: str | None,
non_root_only: bool,
limit: int,
) -> list[Any]:
from sqlalchemy import or_, select
from server.domain.models import Server
q = select(Server).order_by(Server.id)
if server_id is not None:
q = q.where(Server.id == server_id)
elif server_ids:
q = q.where(Server.id.in_(server_ids))
if name_like:
q = q.where(or_(Server.name.like(f"%{name_like}%"), Server.domain.like(f"%{name_like}%")))
if non_root_only:
q = q.where(Server.username.isnot(None)).where(Server.username != "root")
result = await session.execute(q)
servers = list(result.scalars().all())
if limit > 0:
servers = servers[:limit]
return servers
async def run_patch(args: argparse.Namespace) -> list[dict[str, Any]]:
from server.application.services.files_sudoers_service import install_nexus_files_sudoers
from server.config import settings
from server.infrastructure.database.session import AsyncSessionLocal, init_db
from server.infrastructure.redis.client import close_redis, init_redis
await init_db()
await init_redis(settings.REDIS_URL)
ids: list[int] | None = None
if args.ids:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
async with AsyncSessionLocal() as session:
servers = await _fetch_servers(
session,
server_id=args.id,
server_ids=ids,
name_like=args.name,
non_root_only=args.non_root_only,
limit=args.limit,
)
if not servers:
print("No servers matched.", file=sys.stderr)
return []
sem = asyncio.Semaphore(max(1, args.concurrency))
results: list[dict[str, Any]] = []
async def _one(server: Any) -> None:
async with sem:
try:
row = await install_nexus_files_sudoers(server, dry_run=args.dry_run)
except Exception as exc:
row = {
"server_id": server.id,
"server_name": server.name,
"ssh_user": server.username,
"ok": False,
"action": "error",
"detail": str(exc)[:500],
}
results.append(row)
status = "OK" if row.get("ok") else "FAIL"
print(
f"[{status}] #{row.get('server_id')} {row.get('server_name')} "
f"({row.get('ssh_user')}) → {row.get('action')}"
+ (f"{row.get('detail')}" if row.get("detail") else ""),
flush=True,
)
await asyncio.gather(*[_one(s) for s in servers])
from server.infrastructure.redis.client import close_redis
await close_redis()
ok = sum(1 for r in results if r.get("ok"))
fail = len(results) - ok
print(f"\nDone: {ok} ok / {fail} fail / {len(results)} total")
return results
def main() -> int:
_load_dotenv()
parser = argparse.ArgumentParser(description="Batch patch nexus-files sudoers (rsync for push)")
parser.add_argument("--id", type=int, help="Single server id")
parser.add_argument("--ids", type=str, help="Comma-separated server ids")
parser.add_argument("--name", type=str, help="Filter name/domain LIKE")
parser.add_argument("--non-root-only", action="store_true", help="Skip username=root")
parser.add_argument("--limit", type=int, default=0, help="Max servers (0=all)")
parser.add_argument("--concurrency", type=int, default=5)
parser.add_argument("--dry-run", action="store_true", help="Report only, no SSH writes")
args = parser.parse_args()
if not args.id and not args.ids and not args.name and not args.non_root_only:
parser.error("Specify --id, --ids, --name, and/or --non-root-only")
results = asyncio.run(run_patch(args))
return 0 if results and all(r.get("ok") for r in results) else (1 if results else 2)
if __name__ == "__main__":
raise SystemExit(main())
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Batch patch nexus-files sudoers on sub-servers (rsync for file push elevation).
#
# Usage:
# bash scripts/batch_patch_files_sudoers.sh --name 温胜夜
# bash scripts/batch_patch_files_sudoers.sh --non-root-only --limit 100
# bash scripts/batch_patch_files_sudoers.sh --ids 12,34 --dry-run
#
# Run on machine with .env (local dev or ssh nexus + cd /opt/nexus).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SECRETS="${ROOT}/deploy/nexus-1panel.secrets.sh"
if [[ -f "$SECRETS" ]]; then
# shellcheck disable=SC1090
source "$SECRETS"
fi
PYTHON="${ROOT}/.venv/bin/python"
if [[ ! -x "$PYTHON" ]]; then
PYTHON="${ROOT}/venv/bin/python"
fi
if [[ ! -x "$PYTHON" ]]; then
PYTHON=python3
fi
exec "$PYTHON" "${ROOT}/scripts/batch_patch_files_sudoers.py" "$@"
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Verify rsync push elevation for selected servers (diagnose + dry-run + optional probe push).
Usage:
python scripts/verify_push_elevation.py --ids 313,314
python scripts/verify_push_elevation.py --name 温胜夜 --push
"""
from __future__ import annotations
import argparse
import asyncio
import os
import secrets
import sys
import tempfile
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _load_dotenv() -> None:
env_path = ROOT / ".env"
if not env_path.is_file():
return
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip("\"'")
if key.startswith("NEXUS_") and key[6:] not in os.environ:
os.environ[key[6:]] = value
elif key and key not in os.environ:
os.environ[key] = value
async def _fetch_servers(session: Any, *, ids: list[int] | None, name_like: str | None) -> list[Any]:
from sqlalchemy import or_, select
from server.domain.models import Server
q = select(Server).order_by(Server.id)
if ids:
q = q.where(Server.id.in_(ids))
if name_like:
q = q.where(or_(Server.name.like(f"%{name_like}%"), Server.domain.like(f"%{name_like}%")))
result = await session.execute(q)
return list(result.scalars().all())
async def _diagnose_write(server: Any, dest: str) -> dict[str, Any]:
import shlex
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
from server.utils.posix_paths import remote_join
test_file = remote_join(dest, ".nexus_verify_probe")
cmd = f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}"
r = await exec_ssh_command_with_fallback(server, cmd, timeout=15)
return {
"path_writable": r.get("exit_code") == 0,
"elevated": bool(r.get("elevated")),
"stderr": (r.get("stderr") or "")[:200],
}
async def run_verify(args: argparse.Namespace) -> list[dict[str, Any]]:
from server.application.services.files_sudoers_service import probe_rsync_sudo
from server.application.services.sync_engine_v2 import _rsync_push
from server.config import settings
from server.infrastructure.database.session import AsyncSessionLocal, init_db
from server.infrastructure.redis.client import close_redis, init_redis
from server.utils.posix_paths import UPLOAD_STAGING_PREFIX, normalize_remote_abs_path
await init_db()
await init_redis(settings.REDIS_URL)
ids: list[int] | None = None
if args.ids:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
async with AsyncSessionLocal() as session:
servers = await _fetch_servers(session, ids=ids, name_like=args.name)
if not servers:
print("No servers matched.", file=sys.stderr)
return []
staging = f"{UPLOAD_STAGING_PREFIX}{secrets.token_hex(6)}"
os.makedirs(staging, exist_ok=True)
probe_name = ".nexus_push_probe.txt"
probe_path = os.path.join(staging, probe_name)
with open(probe_path, "w", encoding="utf-8") as fh:
fh.write("nexus push elevation verify\n")
results: list[dict[str, Any]] = []
try:
for server in servers:
dest = normalize_remote_abs_path(server.target_path or "/tmp/sync")
row: dict[str, Any] = {
"server_id": server.id,
"server_name": server.name,
"ssh_user": server.username,
"target_path": dest,
}
row["rsync_sudo"] = await probe_rsync_sudo(server)
write = await _diagnose_write(server, dest)
row.update(write_check=write)
preview = await _rsync_push(
server, staging, dest, sync_mode="incremental", dry_run=True
)
row["dry_run_exit"] = preview.get("exit_code")
row["dry_run_ok"] = preview.get("exit_code") in (0, 23)
if not row["dry_run_ok"]:
row["dry_run_stderr"] = (preview.get("stderr") or "")[:300]
if args.push and row["dry_run_ok"]:
push = await _rsync_push(server, staging, dest, sync_mode="incremental")
row["push_exit"] = push.get("exit_code")
row["push_ok"] = push.get("exit_code") == 0
row["push_elevated"] = bool(push.get("elevated"))
if not row["push_ok"]:
row["push_stderr"] = (push.get("stderr") or "")[:300]
ok = (
row.get("rsync_sudo")
and write.get("path_writable")
and row.get("dry_run_ok")
and (not args.push or row.get("push_ok"))
)
row["ok"] = ok
results.append(row)
status = "OK" if ok else "FAIL"
print(
f"[{status}] #{server.id} {server.name} user={server.username} dest={dest}\n"
f" rsync_sudo={row['rsync_sudo']} write={write.get('path_writable')} "
f"elevated={write.get('elevated')} dry_run={row.get('dry_run_exit')}"
+ (
f" push={row.get('push_exit')} push_elevated={row.get('push_elevated')}"
if args.push
else ""
),
flush=True,
)
if row.get("dry_run_stderr"):
print(f" dry_run_err: {row['dry_run_stderr']}", flush=True)
if row.get("push_stderr"):
print(f" push_err: {row['push_stderr']}", flush=True)
finally:
import shutil
shutil.rmtree(staging, ignore_errors=True)
await close_redis()
ok_n = sum(1 for r in results if r.get("ok"))
print(f"\nVerify: {ok_n}/{len(results)} passed")
return results
def main() -> int:
_load_dotenv()
parser = argparse.ArgumentParser(description="Verify push elevation on servers")
parser.add_argument("--ids", type=str, help="Comma-separated server ids")
parser.add_argument("--name", type=str, help="Filter name/domain")
parser.add_argument("--push", action="store_true", help="Run real probe file push after dry-run")
args = parser.parse_args()
if not args.ids and not args.name:
parser.error("Specify --ids or --name")
results = asyncio.run(run_verify(args))
return 0 if results and all(r.get("ok") for r in results) else 1
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
"""Embedded browser UI preferences API."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.domain.models import Admin
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.browser_ui_state import (
BROWSER_UI_CONTEXT,
merge_browser_state,
parse_browser_state,
record_visit,
)
router = APIRouter(prefix="/api/browser", tags=["browser"])
class BrowserVisitRecord(BaseModel):
url: str
title: str | None = None
class BrowserTabState(BaseModel):
id: str
url: str
title: str | None = None
stack: list[str] | None = None
stack_index: int | None = None
class BrowserPanelRect(BaseModel):
x: float
y: float
w: float
h: float
class BrowserStateUpdate(BaseModel):
url_history: list[str] | None = None
visits: list[dict] | None = None
tabs: list[BrowserTabState] | None = None
active_tab_id: str | None = None
minimized: bool | None = None
rect: BrowserPanelRect | None = None
minimized_rect: BrowserPanelRect | None = None
record_url: str | None = Field(default=None, description="Append one visit + history entry")
record_title: str | None = None
@router.get("/state")
async def get_browser_state(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, BROWSER_UI_CONTEXT)
return parse_browser_state(raw)
@router.put("/state")
async def put_browser_state(
payload: BrowserStateUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = AdminUiPreferenceRepositoryImpl(db)
current = parse_browser_state(await repo.get(admin.id, BROWSER_UI_CONTEXT))
incoming = payload.model_dump(exclude_none=True)
if payload.tabs is not None:
incoming["tabs"] = [t.model_dump(exclude_none=True) for t in payload.tabs]
if payload.rect is not None:
incoming["rect"] = payload.rect.model_dump()
if payload.minimized_rect is not None:
incoming["minimized_rect"] = payload.minimized_rect.model_dump()
merged = merge_browser_state(current, incoming)
if payload.record_url:
merged = record_visit(merged, payload.record_url, payload.record_title)
await repo.upsert(admin.id, BROWSER_UI_CONTEXT, merged)
return merged
-159
View File
@@ -1,159 +0,0 @@
"""Browser RDP — Guacamole WebSocket tunnel to guacd (no session audit)."""
from __future__ import annotations
import logging
from typing import Optional
from urllib.parse import parse_qs, unquote
from fastapi import APIRouter, Path, WebSocket, WebSocketDisconnect
from server.config import settings
from server.domain.models import Admin
from server.infrastructure.database.crypto import decrypt_value
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from server.infrastructure.database.rdp_host_repo import RdpHostRepositoryImpl
from server.infrastructure.guacamole.ws_tunnel import run_guacamole_rdp_tunnel
logger = logging.getLogger("nexus.rdp")
router = APIRouter()
async def _verify_rdp_access_token(token: str) -> Optional[Admin]:
"""Verify normal access JWT for RDP (reject webssh-only tokens)."""
try:
import jwt as pyjwt
payload = pyjwt.decode(
token,
settings.SECRET_KEY,
algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
except Exception:
return None
if payload.get("purpose") == "webssh":
return None
admin_id = payload.get("sub")
if not admin_id:
return None
try:
admin_id = int(admin_id)
except (TypeError, ValueError):
return None
async with AsyncSessionLocal() as session:
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
if not admin or not admin.is_active:
return None
token_tv = payload.get("tv") or 0
if token_tv != (admin.token_version or 0):
return None
return admin
def _rdp_password_plain(enc: str) -> str:
plain = str(enc or "").strip()
if not plain:
return ""
try:
return decrypt_value(plain)
except Exception:
return ""
def _parse_display_size(query_string: bytes) -> tuple[int, int]:
qs = parse_qs(query_string.decode("utf-8", errors="replace"))
try:
w = int(qs.get("width", ["1024"])[0])
h = int(qs.get("height", ["768"])[0])
if w > 0 and h > 0:
return w, h
except (TypeError, ValueError):
pass
return 1024, 768
@router.websocket("/ws/rdp/hosts/{host_id}/tunnel/{access_token}")
async def rdp_guacamole_ws(
websocket: WebSocket,
host_id: int = Path(...),
access_token: str = Path(..., description="URL-encoded JWT (not query — Guacamole appends ?connect)"),
):
"""Guacamole WS tunnel: JWT auth, server-side guacd handshake, then relay."""
token = unquote(access_token).strip()
if not token:
await websocket.close(code=4001, reason="Missing JWT token")
return
admin = await _verify_rdp_access_token(token)
if not admin:
await websocket.close(code=4401, reason="Invalid or expired JWT token")
return
if not settings.GUACD_HOST:
await websocket.close(code=4503, reason="guacd not configured")
return
async with AsyncSessionLocal() as session:
repo = RdpHostRepositoryImpl(session)
row = await repo.get_by_id(host_id)
if not row:
await websocket.close(code=4404, reason="RDP host not found")
return
password = _rdp_password_plain(row.password)
if not password:
await websocket.close(code=4400, reason="RDP password not configured")
return
connect_params = {
k: v
for k, v in (
("hostname", row.host.strip()),
("port", str(row.port)),
("username", row.username.strip()),
("password", password),
("security", "any"),
("ignore-cert", "true"),
("disable-wallpaper", "true"),
("disable-theming", "true"),
("enable-font-smoothing", "true"),
("enable-full-window-drag", "false"),
("enable-desktop-composition", "false"),
("enable-menu-animations", "false"),
)
if v
}
width, height = _parse_display_size(websocket.scope.get("query_string", b""))
await websocket.accept(subprotocol="guacamole")
try:
await run_guacamole_rdp_tunnel(
websocket,
settings.GUACD_HOST,
settings.GUACD_PORT,
connect_params,
width=width,
height=height,
)
except WebSocketDisconnect:
pass
except OSError as exc:
logger.warning("RDP guacd tunnel failed host_id=%s: %s", host_id, exc)
try:
await websocket.close(code=1011, reason="guacd unreachable")
except Exception:
pass
except Exception:
logger.exception("RDP tunnel error host_id=%s", host_id)
try:
await websocket.close(code=1011, reason="RDP tunnel error")
except Exception:
pass
-241
View File
@@ -1,241 +0,0 @@
"""Standalone RDP host CRUD + connect params (3389 page, not Server assets)."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.config import settings
from server.domain.models import Admin, AuditLog, RdpHost
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.crypto import decrypt_value, encrypt_value
from server.infrastructure.database.rdp_host_repo import RdpHostRepositoryImpl
from server.utils.rdp_config import DEFAULT_RDP_PORT, normalize_rdp_port
router = APIRouter(prefix="/api/rdp/hosts", tags=["rdp"])
class RdpHostCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
host: str = Field(..., min_length=1, max_length=255)
port: int = Field(DEFAULT_RDP_PORT, ge=1, le=65535)
username: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=1)
description: Optional[str] = Field(None, max_length=2000)
class RdpHostUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
host: Optional[str] = Field(None, min_length=1, max_length=255)
port: Optional[int] = Field(None, ge=1, le=65535)
username: Optional[str] = Field(None, min_length=1, max_length=100)
password: Optional[str] = None
description: Optional[str] = Field(None, max_length=2000)
def _password_set(enc: str | None) -> bool:
return bool(str(enc or "").strip())
def _host_to_dict(row: RdpHost) -> dict:
return {
"id": row.id,
"name": row.name,
"host": row.host,
"port": row.port,
"username": row.username,
"password_set": _password_set(row.password),
"description": row.description or "",
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def _decrypt_password(row: RdpHost) -> str:
enc = str(row.password or "").strip()
if not enc:
return ""
try:
return decrypt_value(enc)
except Exception:
return ""
async def _audit(
db: AsyncSession,
*,
admin: Admin,
action: str,
host_id: int,
detail: str,
request: Request,
) -> None:
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(
AuditLog(
admin_username=admin.username,
action=action,
target_type="rdp_host",
target_id=host_id,
detail=detail,
ip_address=request.client.host if request.client else "",
)
)
@router.get("/", response_model=dict)
async def list_rdp_hosts(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = RdpHostRepositoryImpl(db)
rows = await repo.list_all()
return {"items": [_host_to_dict(r) for r in rows], "total": len(rows)}
@router.post("/", response_model=dict, status_code=201)
async def create_rdp_host(
payload: RdpHostCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = RdpHostRepositoryImpl(db)
name = payload.name.strip()
host = payload.host.strip()
username = payload.username.strip()
password = payload.password.strip()
if not password:
raise HTTPException(status_code=400, detail="请填写 RDP 密码")
row = RdpHost(
name=name,
host=host,
port=normalize_rdp_port(payload.port),
username=username,
password=encrypt_value(password),
description=(payload.description or "").strip() or None,
)
try:
created = await repo.create(row)
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail=f"名称「{name}」已存在")
await _audit(
db,
admin=admin,
action="create_rdp_host",
host_id=created.id,
detail=f"名称={name} 地址={host}:{row.port}",
request=request,
)
return _host_to_dict(created)
@router.put("/{host_id}", response_model=dict)
async def update_rdp_host(
host_id: int,
payload: RdpHostUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = RdpHostRepositoryImpl(db)
row = await repo.get_by_id(host_id)
if not row:
raise HTTPException(status_code=404, detail="RDP 主机不存在")
data = payload.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
row.name = data["name"].strip()
if "host" in data and data["host"] is not None:
row.host = data["host"].strip()
if "port" in data and data["port"] is not None:
row.port = normalize_rdp_port(data["port"])
if "username" in data and data["username"] is not None:
row.username = data["username"].strip()
if "description" in data:
row.description = (data["description"] or "").strip() or None
if "password" in data and data["password"] is not None:
plain = str(data["password"]).strip()
if plain:
row.password = encrypt_value(plain)
try:
updated = await repo.update(row)
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail=f"名称「{row.name}」已存在")
await _audit(
db,
admin=admin,
action="update_rdp_host",
host_id=host_id,
detail=f"名称={updated.name} 地址={updated.host}:{updated.port}",
request=request,
)
return _host_to_dict(updated)
@router.delete("/{host_id}", status_code=204)
async def delete_rdp_host(
host_id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = RdpHostRepositoryImpl(db)
row = await repo.get_by_id(host_id)
if not row:
raise HTTPException(status_code=404, detail="RDP 主机不存在")
name = row.name
if not await repo.delete(host_id):
raise HTTPException(status_code=404, detail="RDP 主机不存在")
await _audit(
db,
admin=admin,
action="delete_rdp_host",
host_id=host_id,
detail=f"名称={name}",
request=request,
)
@router.get("/{host_id}/connect", response_model=dict)
async def get_rdp_host_connect_params(
host_id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
if not settings.GUACD_HOST:
raise HTTPException(status_code=503, detail="服务端未配置 guacd,无法使用浏览器 RDP")
repo = RdpHostRepositoryImpl(db)
row = await repo.get_by_id(host_id)
if not row:
raise HTTPException(status_code=404, detail="RDP 主机不存在")
host = (row.host or "").strip()
if not host:
raise HTTPException(status_code=400, detail="缺少地址(IP/域名)")
password = _decrypt_password(row)
if not password:
raise HTTPException(status_code=400, detail="RDP 密码无效或未设置")
return {
"host_id": row.id,
"name": row.name,
"hostname": host,
"port": row.port,
"username": row.username,
}
+13 -40
View File
@@ -78,26 +78,6 @@ class ServerCreate(BaseModel):
pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)",
)
rdp_enabled: Optional[bool] = Field(
default=None,
description="Enable native RDP launch (.rdp download); stored in extra_attrs",
)
rdp_port: Optional[int] = Field(
default=None,
ge=1,
le=65535,
description="RDP port (default 3389); stored in extra_attrs",
)
rdp_username: Optional[str] = Field(
default=None,
max_length=100,
description="RDP username; stored in extra_attrs",
)
rdp_password: Optional[str] = Field(
default=None,
description="RDP password (encrypted in extra_attrs); write-only",
)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
@@ -130,26 +110,6 @@ class ServerUpdate(BaseModel):
pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)",
)
rdp_enabled: Optional[bool] = Field(
default=None,
description="Enable native RDP launch (.rdp download); stored in extra_attrs",
)
rdp_port: Optional[int] = Field(
default=None,
ge=1,
le=65535,
description="RDP port (default 3389); stored in extra_attrs",
)
rdp_username: Optional[str] = Field(
default=None,
max_length=100,
description="RDP username; stored in extra_attrs",
)
rdp_password: Optional[str] = Field(
default=None,
description="RDP password (encrypted in extra_attrs); write-only",
)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
@@ -195,6 +155,19 @@ class BatchCategoryUpdate(BaseModel):
category: str = Field("", max_length=100, description="分类名称;留空表示清除分类")
class ServerSearchHistoryUpdate(BaseModel):
"""Record or replace servers page search history for current admin."""
query: Optional[str] = Field(None, max_length=200, description="记录一次搜索;空字符串清除 last")
history: Optional[List[str]] = Field(None, max_length=12, description="整表替换历史(迁移/恢复)")
last: Optional[str] = Field(None, max_length=200, description="与 history 联用时指定 last")
@model_validator(mode="after")
def _require_mutation(self) -> "ServerSearchHistoryUpdate":
if self.query is None and self.history is None:
raise ValueError("query 或 history 至少提供一个")
return self
class BatchCategoryResult(BaseModel):
updated: int = 0
not_found: List[int] = []
+59 -117
View File
@@ -18,7 +18,7 @@ from server.api.auth_jwt import get_current_admin
from server.api.schemas import (
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
ServerImportResult, BatchAgentAction, AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult,
BatchCategoryUpdate, BatchJobStarted, BatchJobStatus,
BatchCategoryUpdate, BatchJobStarted, BatchJobStatus, ServerSearchHistoryUpdate,
)
from server.application.server_batch_common import (
install_error_msg as _install_error_msg,
@@ -289,6 +289,48 @@ async def server_categories(
return {"categories": categories, "platforms": platforms}
@router.get("/search-history", response_model=dict)
async def get_server_search_history(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Return servers page search history for the current admin."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
SERVERS_SEARCH_CONTEXT,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
raw = await repo.get(admin.id, SERVERS_SEARCH_CONTEXT)
return parse_search_state(raw)
@router.put("/search-history", response_model=dict)
async def update_server_search_history(
payload: ServerSearchHistoryUpdate,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Record a search query or replace full history for the current admin."""
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
from server.utils.server_search_history import (
SERVERS_SEARCH_CONTEXT,
apply_search_record,
apply_search_replace,
parse_search_state,
)
repo = AdminUiPreferenceRepositoryImpl(db)
state = parse_search_state(await repo.get(admin.id, SERVERS_SEARCH_CONTEXT))
if payload.history is not None:
state = apply_search_replace(history=payload.history, last=payload.last)
else:
state = apply_search_record(state, payload.query or "")
await repo.upsert(admin.id, SERVERS_SEARCH_CONTEXT, state)
return state
@router.get("/logs", response_model=dict)
async def get_all_sync_logs(
server_id: Optional[int] = Query(None, description="Filter by server ID"),
@@ -1138,44 +1180,6 @@ async def get_server(
return server_data
@router.get("/{id}/rdp-connect", response_model=dict)
async def get_rdp_connect_params(
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""RDP parameters for in-browser Guacamole client (admin JWT; no session audit)."""
from server.config import settings
from server.utils.rdp_config import get_rdp_config, get_rdp_password_plain
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
cfg = get_rdp_config(server)
if not cfg.enabled:
raise HTTPException(status_code=400, detail="此服务器未启用 RDP")
host = (server.domain or "").strip()
if not host:
raise HTTPException(status_code=400, detail="服务器缺少地址(域名/IP")
password = get_rdp_password_plain(server)
if not password:
raise HTTPException(status_code=400, detail="请先在服务器设置中填写 RDP 密码")
if not settings.GUACD_HOST:
raise HTTPException(status_code=503, detail="服务端未配置 guacd,无法使用浏览器 RDP")
return {
"server_id": server.id,
"server_name": server.name,
"hostname": host,
"port": cfg.port,
"username": cfg.username,
"password": password,
}
@router.get("/{id}/metrics", response_model=dict)
async def get_server_metrics(
id: int,
@@ -1242,15 +1246,11 @@ async def setup_files_sudo(
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""Auto-configure passwordless sudoers for file manager on non-root servers.
Uses the stored SSH password to run `sudo -S` and write
/etc/sudoers.d/nexus-files on the target server.
Requires the SSH user to have sudo access with password.
"""
import textwrap
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
from server.infrastructure.database.crypto import decrypt_value
"""Auto-configure passwordless sudoers for file manager + rsync push on non-root servers."""
from server.application.services.files_sudoers_service import (
NEXUS_FILES_SUDOERS_PATH,
install_nexus_files_sudoers,
)
server = await service.get_server(id)
if not server:
@@ -1260,70 +1260,23 @@ async def setup_files_sudo(
if ssh_user == "root":
return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"}
if not server.password:
raise HTTPException(
status_code=400,
detail="该服务器未存储 SSH 密码,无法自动配置。请手动在目标机配置 sudoers。",
)
plain_password = decrypt_value(server.password)
sudoers_content = textwrap.dedent(f"""\
# nexus-files: auto-configured by Nexus file manager
{ssh_user} ALL=(root) NOPASSWD: \\
/bin/ls, /usr/bin/ls, \\
/bin/cat, /usr/bin/cat, \\
/usr/bin/tee, /bin/tee, \\
/bin/mkdir, /usr/bin/mkdir, \\
/bin/cp, /usr/bin/cp, \\
/bin/mv, /usr/bin/mv, \\
/bin/rm, /usr/bin/rm, \\
/bin/chmod, /usr/bin/chmod, \\
/bin/chown, /usr/bin/chown, \\
/bin/bash, /usr/bin/bash
""")
sudoers_path = "/etc/sudoers.d/nexus-files"
# Use heredoc via sudo -S to avoid exposing password in command arguments
write_cmd = (
f"printf %s {shlex.quote(sudoers_content)}"
f" | sudo -S tee {shlex.quote(sudoers_path)} > /dev/null"
f" && sudo chmod 440 {shlex.quote(sudoers_path)}"
f" && sudo visudo -cf {shlex.quote(sudoers_path)}"
)
conn = None
try:
conn = await ssh_pool.acquire(server)
# Feed password via stdin for sudo -S (avoids exposing password in process args)
result = await asyncio.wait_for(
conn.run(write_cmd, input=plain_password + "\n", timeout=15),
timeout=20,
)
exit_code = result.exit_status if result.exit_status is not None else -1
stderr_out = (result.stderr or "").strip()
stdout_out = (result.stdout or "").strip()
if exit_code != 0:
detail = stderr_out or stdout_out or f"命令退出码 {exit_code}"
raise HTTPException(
status_code=500,
detail=f"sudoers 写入失败:{detail[:300]}",
)
except HTTPException:
raise
except Exception as exc:
outcome = await install_nexus_files_sudoers(server)
if outcome.get("action") == "already_ok":
return {
"ok": True,
"message": f"sudo -n rsync 已可用,无需重复配置({NEXUS_FILES_SUDOERS_PATH}",
}
if not outcome.get("ok"):
raise HTTPException(
status_code=500,
detail=f"SSH 执行失败:{exc}",
) from exc
finally:
if conn is not None:
await ssh_pool.release(server.id)
detail=outcome.get("detail") or f"sudoers 配置失败:{outcome.get('action')}",
)
return {
"ok": True,
"message": (
f"sudoers 已配置:{sudoers_path}"
"现在文件管理可使用 sudo -n 自动提权浏览/操作文件"
f"sudoers 已配置:{NEXUS_FILES_SUDOERS_PATH}"
"文件管理与文件推送可使用 sudo -n 自动提权。"
),
}
@@ -1342,11 +1295,9 @@ async def create_server(
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
from server.utils.files_elevation import apply_files_elevation_payload
from server.utils.rdp_config import apply_rdp_payload
data = payload.model_dump(exclude_none=True)
apply_files_elevation_payload(data)
apply_rdp_payload(data)
# If preset_id is provided, resolve the preset password (server-side, no re-auth needed)
if data.get("preset_id") and data.get("auth_method") == "password" and not data.get("password"):
@@ -1417,12 +1368,10 @@ async def update_server(
raise HTTPException(status_code=404, detail="Server not found")
from server.utils.files_elevation import apply_files_elevation_payload
from server.utils.rdp_config import apply_rdp_payload
# If preset_id is provided (and not None), resolve the preset password
update_data = payload.model_dump(exclude_unset=True)
apply_files_elevation_payload(update_data)
apply_rdp_payload(update_data, existing_extra=server.extra_attrs)
if "preset_id" in update_data and update_data["preset_id"] is not None and not update_data.get("password"):
preset_repo = PasswordPresetRepositoryImpl(db)
preset = await preset_repo.get_by_id(update_data["preset_id"])
@@ -2059,9 +2008,6 @@ def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict
def _server_to_dict(server: Server) -> dict:
"""Convert Server model to API response dict (hide sensitive fields)"""
from server.utils.files_elevation import get_files_elevation
from server.utils.rdp_config import get_rdp_config
rdp = get_rdp_config(server)
return {
"id": server.id,
@@ -2087,10 +2033,6 @@ def _server_to_dict(server: Server) -> dict:
"protocols": server.protocols,
"extra_attrs": server.extra_attrs,
"files_elevation": get_files_elevation(server).value,
"rdp_enabled": rdp.enabled,
"rdp_port": rdp.port,
"rdp_username": rdp.username,
"rdp_password_set": rdp.password_set,
"connectivity": server.connectivity,
"ssh_key_configured": server.ssh_key_configured,
"is_online": server.is_online,
+64 -35
View File
@@ -1321,6 +1321,28 @@ class IpAllowlistSaveRequest(BaseModel):
enabled: Optional[bool] = None # None = don't change
def _allowlist_save_response(*, manual_count: int, refresh, has_subscription: bool) -> dict:
"""Build POST /ip-allowlist response with subscription refresh outcome."""
if not has_subscription:
return {
"success": True,
"manual_count": manual_count,
"subscription_ips": [],
"subscription_count": 0,
"last_refresh": None,
"refresh_ok": True,
}
return {
"success": True,
"manual_count": manual_count,
"subscription_ips": list(refresh.subscription_ips),
"subscription_count": refresh.subscription_count,
"last_refresh": refresh.last_refresh or None,
"refresh_ok": refresh.ok,
"refresh_error": refresh.error if not refresh.ok else None,
}
@router.post("/ip-allowlist", response_model=dict)
async def set_ip_allowlist(
payload: IpAllowlistSaveRequest,
@@ -1330,37 +1352,44 @@ async def set_ip_allowlist(
):
"""Save manual IPs, subscription URL, and/or enabled toggle."""
from server.config import settings as _settings
from server.background.ip_allowlist_refresh import RefreshResult, do_refresh
from server.infrastructure.settings_broadcast import publish_setting_changes
if payload.manual_ips:
_validate_ip_list(payload.manual_ips)
repo = SettingRepositoryImpl(db)
# Toggle enabled/disabled
if payload.enabled is not None:
val = "true" if payload.enabled else "false"
await repo.set("login_allowlist_enabled", val)
_settings.LOGIN_ALLOWLIST_ENABLED = val
# Manual IPs
manual_cleaned = [ip.strip() for ip in payload.manual_ips if ip.strip()]
manual_val = ",".join(manual_cleaned)
await repo.set("login_manual_ips", manual_val)
_settings.LOGIN_MANUAL_IPS = manual_val
changes: dict[str, str] = {"login_manual_ips": manual_val}
if payload.enabled is not None:
val = "true" if payload.enabled else "false"
await repo.set("login_allowlist_enabled", val)
changes["login_allowlist_enabled"] = val
# Subscription URL
sub_url_changed = False
if payload.subscription_url is not None:
sub_url = payload.subscription_url.strip()
await repo.set("login_subscription_url", sub_url)
_settings.LOGIN_SUBSCRIPTION_URL = sub_url
sub_url_changed = bool(sub_url)
changes["login_subscription_url"] = sub_url
# If no subscription URL, rebuild combined from manual only
if not _settings.LOGIN_SUBSCRIPTION_URL:
if payload.subscription_url is not None:
sub_url_now = payload.subscription_url.strip()
else:
sub_url_now = (_settings.LOGIN_SUBSCRIPTION_URL or "").strip()
refresh = RefreshResult(ok=True)
if not sub_url_now:
await repo.set("login_allowed_ips", manual_val)
await repo.set("login_subscription_ips", "")
_settings.LOGIN_ALLOWED_IPS = manual_val
_settings.LOGIN_SUBSCRIPTION_IPS = ""
changes["login_allowed_ips"] = manual_val
changes["login_subscription_ips"] = ""
await publish_setting_changes(changes)
if sub_url_now:
refresh = await do_refresh()
audit_repo = AuditLogRepositoryImpl(db)
from server.domain.models import AuditLog
@@ -1373,13 +1402,11 @@ async def set_ip_allowlist(
ip_address=ip_address,
))
# Trigger refresh if subscription URL is present
if sub_url_changed or _settings.LOGIN_SUBSCRIPTION_URL:
from server.background.ip_allowlist_refresh import _do_refresh
import asyncio
asyncio.create_task(_do_refresh())
return {"success": True, "manual_count": len(manual_cleaned)}
return _allowlist_save_response(
manual_count=len(manual_cleaned),
refresh=refresh,
has_subscription=bool(sub_url_now),
)
@router.post("/ip-allowlist/toggle", response_model=dict)
@@ -1390,12 +1417,12 @@ async def toggle_ip_allowlist(
db: AsyncSession = Depends(get_db),
):
"""Quick toggle for allowlist enforcement (no IP list changes)."""
from server.config import settings as _settings
val = "true" if enabled else "false"
repo = SettingRepositoryImpl(db)
await repo.set("login_allowlist_enabled", val)
_settings.LOGIN_ALLOWLIST_ENABLED = val
from server.infrastructure.settings_broadcast import publish_setting_changes
await publish_setting_changes({"login_allowlist_enabled": val})
audit_repo = AuditLogRepositoryImpl(db)
from server.domain.models import AuditLog
@@ -1426,7 +1453,11 @@ async def add_manual_ips(
val = ",".join(merged)
repo = SettingRepositoryImpl(db)
await repo.set("login_manual_ips", val)
_settings.LOGIN_MANUAL_IPS = val
from server.infrastructure.settings_broadcast import publish_setting_changes
from server.background.ip_allowlist_refresh import do_refresh
await publish_setting_changes({"login_manual_ips": val})
await do_refresh()
# S-07: Audit log
audit_repo = AuditLogRepositoryImpl(db)
@@ -1438,9 +1469,6 @@ async def add_manual_ips(
ip_address=request.client.host if request.client else "",
))
from server.background.ip_allowlist_refresh import _do_refresh
import asyncio
asyncio.create_task(_do_refresh())
return {"success": True, "manual_ips": merged}
@@ -1459,7 +1487,11 @@ async def remove_allowlist_ip(
val = ",".join(remaining)
repo = SettingRepositoryImpl(db)
await repo.set("login_manual_ips", val)
_settings.LOGIN_MANUAL_IPS = val
from server.infrastructure.settings_broadcast import publish_setting_changes
from server.background.ip_allowlist_refresh import do_refresh
await publish_setting_changes({"login_manual_ips": val})
await do_refresh()
# S-07: Audit log
audit_repo = AuditLogRepositoryImpl(db)
@@ -1471,9 +1503,6 @@ async def remove_allowlist_ip(
ip_address=request.client.host if request.client else "",
))
from server.background.ip_allowlist_refresh import _do_refresh
import asyncio
asyncio.create_task(_do_refresh())
return {"success": True, "removed": ip, "remaining": remaining}
+43 -20
View File
@@ -626,6 +626,7 @@ async def diagnose_push(
"""
import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.utils.sync_error_message import translate_sync_error_message
@@ -643,6 +644,7 @@ async def diagnose_push(
"path_exists": None,
"path_perms": None,
"path_writable": None,
"path_elevated": False,
"errors": [],
}
@@ -699,16 +701,15 @@ async def diagnose_push(
# 4. Write test
if result["path_exists"]:
try:
test_file = remote_join(dest, ".nexus_diag_test_$$")
r = await exec_ssh_command(
server,
f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}",
timeout=10,
)
test_file = remote_join(dest, ".nexus_diag_test")
write_cmd = f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}"
r = await exec_ssh_command_with_fallback(server, write_cmd, timeout=10)
result["path_writable"] = r["exit_code"] == 0
if r["exit_code"] != 0:
stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or ""
result["errors"].append(f"目标路径不可写: {stderr_zh}")
elif r.get("elevated"):
result["path_elevated"] = True
except Exception as e:
result["errors"].append(f"写入测试失败: {e}")
result["path_writable"] = False
@@ -864,7 +865,7 @@ async def file_diff(
# ── File Upload via SFTP ──
MAX_UPLOAD_FILE_SIZE = 104_857_600 # 100 MB
MAX_UPLOAD_FILE_SIZE = 524_288_000 # 500 MB
def _collect_multipart_upload_files(form) -> list:
@@ -927,11 +928,22 @@ async def _sftp_upload_one(
except Exception as e:
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e
from server.utils.files_upload_permissions import apply_upload_file_permissions
permission_fixup = await apply_upload_file_permissions(server, full_remote_path)
audit_detail = f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}"
if permission_fixup.get("applied"):
pf_owner = permission_fixup.get("owner") or ""
pf_group = permission_fixup.get("group") or pf_owner
pf_mode = permission_fixup.get("mode") or ""
audit_detail += f" [chown {pf_owner}:{pf_group} chmod {pf_mode}]"
await _audit_sync(
"file_upload",
"server",
server_id,
f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}",
audit_detail,
admin_username,
request,
)
@@ -940,6 +952,7 @@ async def _sftp_upload_one(
"remote_path": full_remote_path,
"filename": safe_filename,
"size": len(content),
"permission_fixup": permission_fixup,
}
@@ -1009,6 +1022,7 @@ async def upload_file(
"remote_path": one["remote_path"],
"filename": one["filename"],
"size": one["size"],
"permission_fixup": one.get("permission_fixup"),
}
return {
@@ -1453,8 +1467,8 @@ async def download_file(
await ssh_pool.release(server.id)
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
# H-15: Download file size limit (100MB)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
# H-15: Download file size limit (500MB)
MAX_DOWNLOAD_SIZE = 524_288_000
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
await ssh_pool.release(server.id)
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
@@ -1727,21 +1741,30 @@ async def chmod_file(
chmod_prefix = "chmod -R " if recursive else "chmod "
chown_prefix = "chown -R " if recursive else "chown "
cmd_parts = [f"{chmod_prefix}{shlex.quote(payload.mode)} {path_q}"]
if payload.owner:
group = (payload.group or payload.owner).strip()
owner_spec = shlex.quote(f"{payload.owner}:{group}")
cmd_parts.append(f"{chown_prefix}{owner_spec} {path_q}")
timeout = RECURSIVE_CHMOD_TIMEOUT_SEC if recursive else SINGLE_CHMOD_TIMEOUT_SEC
result = await exec_ssh_command_with_fallback(server, " && ".join(cmd_parts), timeout=timeout)
if result["exit_code"] != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:300]
chmod_cmd = f"{chmod_prefix}{payload.mode} {path_q}"
chmod_result = await exec_ssh_command_with_fallback(server, chmod_cmd, timeout=timeout)
if chmod_result["exit_code"] != 0:
err = ((chmod_result.get("stderr") or "") + (chmod_result.get("stdout") or ""))[:300]
raise HTTPException(
status_code=403 if "permission denied" in err.lower() else 502,
detail=err or "chmod 失败",
)
elevated = bool(chmod_result.get("elevated"))
if payload.owner:
group = (payload.group or payload.owner).strip()
owner_spec = shlex.quote(f"{payload.owner}:{group}")
chown_cmd = f"{chown_prefix}{owner_spec} {path_q}"
chown_result = await exec_ssh_command_with_fallback(server, chown_cmd, timeout=timeout)
if chown_result["exit_code"] != 0:
err = ((chown_result.get("stderr") or "") + (chown_result.get("stdout") or ""))[:300]
raise HTTPException(
status_code=403 if "permission denied" in err.lower() else 502,
detail=f"权限已修改为 {payload.mode},但 chown 失败: {err or '未知错误'}",
)
elevated = elevated or bool(chown_result.get("elevated"))
audit_bits = [f"chmod {'-R ' if recursive else ''}{payload.mode}"]
if payload.owner:
audit_bits.append(
@@ -1767,7 +1790,7 @@ async def chmod_file(
"owner": payload.owner,
"group": payload.group,
"recursive": recursive,
"elevated": bool(result.get("elevated")),
"elevated": elevated,
}
@@ -28,10 +28,22 @@ def browse_cache_key(server_id: int, path: str) -> str:
def compute_browse_etag(path: str, items: list[dict[str, Any]]) -> str:
"""Weak ETag from path + sorted entry fingerprints (name|mtime|size)."""
"""Weak ETag from path + sorted entry fingerprints (name|mtime|size|perms|owner|group)."""
lines = [path]
for item in sorted(items, key=lambda x: x.get("name") or ""):
lines.append(f"{item.get('name', '')}|{item.get('modified', '')}|{item.get('size', 0)}")
lines.append(
"|".join(
[
str(item.get("name", "")),
str(item.get("modified", "")),
str(item.get("size", 0)),
str(item.get("permissions", "")),
str(item.get("owner", "")),
str(item.get("group", "")),
str(item.get("mode_octal", "")),
]
)
)
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
return f'W/"{digest}"'
@@ -0,0 +1,140 @@
"""Shared Nexus files sudoers template and remote install helpers."""
from __future__ import annotations
import asyncio
import shlex
import textwrap
from server.domain.models import Server
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command, ssh_pool
from server.infrastructure.database.crypto import decrypt_value
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
NEXUS_FILES_SUDOERS_PATH = "/etc/sudoers.d/nexus-files"
def build_nexus_files_sudoers(ssh_user: str) -> str:
"""Return sudoers.d fragment for file manager + rsync push elevation."""
user = (ssh_user or "deploy").strip() or "deploy"
return textwrap.dedent(f"""\
# nexus-files: configured by Nexus (file manager + rsync push)
{user} ALL=(root) NOPASSWD: \\
/bin/ls, /usr/bin/ls, \\
/bin/cat, /usr/bin/cat, \\
/usr/bin/tee, /bin/tee, \\
/bin/mkdir, /usr/bin/mkdir, \\
/bin/cp, /usr/bin/cp, \\
/bin/mv, /usr/bin/mv, \\
/bin/rm, /usr/bin/rm, \\
/bin/chmod, /usr/bin/chmod, \\
/bin/chown, /usr/bin/chown, \\
/bin/bash, /usr/bin/bash, \\
/usr/bin/rsync, /bin/rsync
""")
async def probe_rsync_sudo(server: Server, *, timeout: int = 15) -> bool:
"""True when ``sudo -n rsync`` works on the target."""
ssh_user = (server.username or "root").strip() or "root"
if ssh_user == "root":
result = await exec_ssh_command(server, "rsync --version", timeout=timeout)
return result.get("exit_code") == 0
result = await exec_ssh_command(server, "sudo -n rsync --version", timeout=timeout)
return result.get("exit_code") == 0
async def _write_sudoers_with_password(
server: Server,
content: str,
sudoers_path: str,
plain_password: str,
) -> dict:
write_cmd = (
f"printf %s {shlex.quote(content)}"
f" | sudo -S tee {shlex.quote(sudoers_path)} > /dev/null"
f" && sudo chmod 440 {shlex.quote(sudoers_path)}"
f" && sudo visudo -cf {shlex.quote(sudoers_path)}"
)
conn = None
try:
conn = await ssh_pool.acquire(server)
result = await asyncio.wait_for(
conn.run(write_cmd, input=plain_password + "\n", timeout=20),
timeout=25,
)
exit_code = result.exit_status if result.exit_status is not None else -1
stderr_out = (result.stderr or "").strip()
stdout_out = (result.stdout or "").strip()
if exit_code != 0:
detail = stderr_out or stdout_out or f"exit {exit_code}"
return {"ok": False, "action": "password_sudo_failed", "detail": detail[:500]}
return {"ok": True, "action": "patched_password_sudo"}
finally:
if conn is not None:
await ssh_pool.release(server.id)
async def install_nexus_files_sudoers(server: Server, *, dry_run: bool = False) -> dict:
"""Ensure ``/etc/sudoers.d/nexus-files`` includes rsync (and file ops whitelist).
Returns ``{ok, action, server_id, server_name, ssh_user, detail?}``.
"""
ssh_user = (server.username or "root").strip() or "root"
base = {
"server_id": server.id,
"server_name": server.name,
"ssh_user": ssh_user,
}
if ssh_user == "root":
return {**base, "ok": True, "action": "skip_root"}
if await probe_rsync_sudo(server):
return {**base, "ok": True, "action": "already_ok"}
if dry_run:
return {**base, "ok": True, "action": "would_patch"}
content = build_nexus_files_sudoers(ssh_user)
sudoers_path = NEXUS_FILES_SUDOERS_PATH
if server.password:
plain = decrypt_value(server.password)
outcome = await _write_sudoers_with_password(server, content, sudoers_path, plain)
if not outcome["ok"]:
return {**base, **outcome}
if await probe_rsync_sudo(server):
return {**base, **outcome}
return {
**base,
"ok": False,
"action": "written_but_rsync_probe_failed",
"detail": "sudoers 已写入但 sudo -n rsync 仍失败",
}
write_inner = (
f"printf %s {shlex.quote(content)}"
f" | tee {shlex.quote(sudoers_path)} > /dev/null"
f" && chmod 440 {shlex.quote(sudoers_path)}"
f" && visudo -cf {shlex.quote(sudoers_path)}"
)
result = await exec_ssh_command_with_fallback(server, write_inner, timeout=30)
if result.get("exit_code") != 0:
detail = (result.get("stderr") or result.get("stdout") or "")[:500]
return {
**base,
"ok": False,
"action": "nopasswd_write_failed",
"detail": detail or "无法写入 sudoers(需存储 SSH 密码或已配置 bash 免密 sudo)",
}
if await probe_rsync_sudo(server):
return {**base, "ok": True, "action": "patched_nopasswd"}
return {
**base,
"ok": False,
"action": "written_but_rsync_probe_failed",
"detail": "sudoers 已写入但 sudo -n rsync 仍失败,请检查 visudo 与白名单路径",
}
+40 -12
View File
@@ -43,9 +43,10 @@ _RSYNC_CHOWN_RE = re.compile(r"^[a-zA-Z0-9_.-]+(:[a-zA-Z0-9_.-]+)?$")
_RSYNC_CHMOD_RE = re.compile(r"^[DF0-9a-zA-Z=+,:-]+$")
def rsync_push_permission_summary() -> str | None:
"""Human/log snapshot of rsync --chown/--chmod applied on real pushes."""
def rsync_push_permission_summary(server: Server | None = None) -> str | None:
"""Human/log snapshot of rsync --chown/--chmod and elevation applied on real pushes."""
from server.config import settings
from server.utils.rsync_elevation import rsync_elevation_log_hint
parts: list[str] = []
chown = (settings.RSYNC_PUSH_CHOWN or "").strip()
@@ -54,6 +55,10 @@ def rsync_push_permission_summary() -> str | None:
parts.append(f"chown={chown}")
if chmod and _RSYNC_CHMOD_RE.fullmatch(chmod):
parts.append(f"chmod={chmod}")
if server is not None:
elevation_hint = rsync_elevation_log_hint(server)
if elevation_hint:
parts.append(elevation_hint)
return " ".join(parts) if parts else None
@@ -185,7 +190,7 @@ class SyncEngineV2:
operator=operator,
status="running",
sync_mode=sync_mode,
push_permission=rsync_push_permission_summary(),
push_permission=rsync_push_permission_summary(server),
started_at=datetime.now(timezone.utc),
)
async with _db_lock:
@@ -452,9 +457,36 @@ async def _rsync_push(
) -> 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}.
Non-root servers with ``files_elevation`` not ``off`` use
``--rsync-path=sudo -n rsync`` (default ``auto_sudo``, same as file manager).
"""
from server.utils.rsync_elevation import rsync_receiver_path_for_push
sudo_path = rsync_receiver_path_for_push(server)
result = await _execute_rsync_push(
server,
source_path,
target_path,
sync_mode=sync_mode,
dry_run=dry_run,
verbose=verbose,
rsync_receiver_path=sudo_path,
)
result["elevated"] = bool(sudo_path and result.get("exit_code") == 0)
return result
async def _execute_rsync_push(
server: Server,
source_path: str,
target_path: str,
*,
sync_mode: str = "incremental",
dry_run: bool = False,
verbose: bool = False,
rsync_receiver_path: str | None = None,
) -> dict:
"""Run one rsync attempt on the Nexus host. Returns {exit_code, stdout, stderr}."""
import shlex
ssh_user = server.username or "root"
@@ -463,7 +495,6 @@ async def _rsync_push(
remote_dest = normalize_remote_abs_path(to_posix(target_path))
rsync_remote = f"{ssh_user}@{ssh_host}:{remote_dest}"
# Build rsync args
args = ["rsync", "-az"]
if sync_mode == "full":
args.append("--delete")
@@ -481,18 +512,17 @@ async def _rsync_push(
if not dry_run:
args.extend(rsync_push_permission_args())
# SSH options: port + no strict host key checking (matches asyncssh pool behavior)
if rsync_receiver_path:
args.extend(["--rsync-path", rsync_receiver_path])
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"
@@ -512,11 +542,9 @@ async def _rsync_push(
return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"}
args.extend(["-e", ssh_opts])
# source_path: Nexus 主机本地目录(生产环境为 Linux);保持原样供 rsync 读取
args.append(source_path.rstrip("/") + "/")
args.append(rsync_remote.rstrip("/") + "/")
# Execute rsync on Nexus host
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
+37 -8
View File
@@ -8,8 +8,11 @@ Data model:
login_allowlist_enabled "true" / "false" master switch
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
logger = logging.getLogger("nexus.ip_allowlist_refresh")
@@ -18,18 +21,32 @@ _last_refresh_at: str = ""
_last_subscription_count: int = 0
@dataclass(frozen=True)
class RefreshResult:
ok: bool
subscription_ips: tuple[str, ...] = ()
last_refresh: str = ""
subscription_count: int = 0
error: str | None = None
async def ip_allowlist_refresh_loop():
"""Every 2 hours, re-fetch subscription IPs and rebuild combined allowlist."""
logger.info("IP allowlist refresh loop started (interval=%sh)", REFRESH_INTERVAL // 3600)
# Initial refresh shortly after startup
await asyncio.sleep(5)
await _do_refresh()
await do_refresh()
while True:
await asyncio.sleep(REFRESH_INTERVAL)
await _do_refresh()
await do_refresh()
async def _do_refresh():
async def do_refresh() -> RefreshResult:
"""Fetch subscription, replace subscription IPs, rebuild combined list."""
return await _do_refresh()
async def _do_refresh() -> RefreshResult:
"""Fetch subscription, replace subscription IPs, rebuild combined list."""
global _last_refresh_at, _last_subscription_count
from server.config import settings
@@ -37,7 +54,7 @@ async def _do_refresh():
sub_url = (settings.LOGIN_SUBSCRIPTION_URL or "").strip()
if not sub_url:
return
return RefreshResult(ok=True)
logger.info("Refreshing subscription IPs from: %s", sub_url[:80])
try:
@@ -46,11 +63,11 @@ async def _do_refresh():
resp = await client.get(sub_url, follow_redirects=True)
if resp.status_code != 200:
logger.warning("Subscription returned HTTP %s", resp.status_code)
return
return RefreshResult(ok=False, error=f"订阅链接返回 HTTP {resp.status_code}")
new_sub_ips = parse_subscription_content(resp.text)
except Exception as e:
logger.error("Subscription fetch failed: %s", e)
return
return RefreshResult(ok=False, error=f"拉取订阅失败: {e}")
# Manual IPs (always preserved)
manual_raw = (settings.LOGIN_MANUAL_IPS or "").strip()
@@ -70,16 +87,21 @@ async def _do_refresh():
try:
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.settings_broadcast import publish_setting_changes
async with AsyncSessionLocal() as session:
repo = SettingRepositoryImpl(session)
# Subscription IPs — replaced entirely
await repo.set("login_subscription_ips", sub_val)
# Combined — for auth check
await repo.set("login_allowed_ips", combined_val)
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
settings.LOGIN_ALLOWED_IPS = combined_val
await publish_setting_changes({
"login_subscription_ips": sub_val,
"login_allowed_ips": combined_val,
})
from server.utils.display_time import format_beijing
_last_refresh_at = format_beijing(with_label=False)
@@ -88,8 +110,15 @@ async def _do_refresh():
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
len(new_sub_ips), len(combined), len(manual_ips),
)
return RefreshResult(
ok=True,
subscription_ips=tuple(new_sub_ips),
last_refresh=_last_refresh_at,
subscription_count=len(new_sub_ips),
)
except Exception as e:
logger.error("Failed to save subscription IPs: %s", e)
return RefreshResult(ok=False, error=f"保存订阅 IP 失败: {e}")
def get_last_refresh_time() -> str:
-4
View File
@@ -57,10 +57,6 @@ class Settings(BaseSettings):
# SSH — 运维平台连接大量未知子机,默认跳过主机密钥校验(安装向导可在 .env 写 true 覆盖)
SSH_STRICT_HOST_CHECKING: str = "false"
# Browser RDP (guacd sidecar)
GUACD_HOST: str = ""
GUACD_PORT: int = 4822
# JWT (mutable via .env / settings table — not immutable secrets)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
+10 -19
View File
@@ -202,6 +202,16 @@ class Admin(Base):
token_version = Column(Integer, default=0, comment="令牌版本(重用时递增使旧token失效)")
class AdminUiPreference(Base):
"""Per-admin UI state (search history, etc.) — JSON payload keyed by context."""
__tablename__ = "admin_ui_preferences"
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="CASCADE"), primary_key=True)
context = Column(String(50), primary_key=True, comment="UI 上下文,如 servers_search")
payload = Column(JSON, nullable=False, comment='JSON: {"history":[],"last":null}')
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
class LoginAttempt(Base):
"""Login attempt log for brute-force protection"""
__tablename__ = "login_attempts"
@@ -530,22 +540,3 @@ class QuickCommand(Base):
created_by = Column(String(100), nullable=True, comment="创建人")
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
# ──────────────────────────────────────────────
# RDP Hosts (3389 独立远程桌面,不关联子机表)
# ──────────────────────────────────────────────
class RdpHost(Base):
"""Standalone Windows RDP endpoint for browser Guacamole (not tied to Server assets)."""
__tablename__ = "rdp_hosts"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False, comment="显示名称")
host = Column(String(255), nullable=False, comment="IP 或域名")
port = Column(Integer, default=3389, nullable=False, comment="RDP 端口")
username = Column(String(100), nullable=False, comment="RDP 用户名")
password = Column(Text, nullable=False, comment="RDP 密码(Fernet 加密)")
description = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
@@ -0,0 +1,55 @@
"""Admin UI preference repository — per-admin JSON blobs (search history, etc.)."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import AdminUiPreference
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
class AdminUiPreferenceRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get(self, admin_id: int, context: str) -> dict | None:
result = await self.session.execute(
select(AdminUiPreference).where(
AdminUiPreference.admin_id == admin_id,
AdminUiPreference.context == context,
)
)
row = result.scalar_one_or_none()
if not row or not isinstance(row.payload, dict):
return None
return dict(row.payload)
async def upsert(self, admin_id: int, context: str, payload: dict) -> dict:
result = await self.session.execute(
select(AdminUiPreference).where(
AdminUiPreference.admin_id == admin_id,
AdminUiPreference.context == context,
)
)
row = result.scalar_one_or_none()
now = _utcnow()
if row:
row.payload = payload
row.updated_at = now
else:
self.session.add(
AdminUiPreference(
admin_id=admin_id,
context=context,
payload=payload,
updated_at=now,
)
)
await self.session.commit()
return payload
+7 -10
View File
@@ -209,17 +209,14 @@ async def run_schema_migrations():
UNIQUE KEY `uq_admin_refresh_token_hash` (`token_hash`),
INDEX `idx_admin_refresh_admin_expires` (`admin_id`, `expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS `rdp_hosts` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL COMMENT '显示名称',
`host` VARCHAR(255) NOT NULL COMMENT 'IP 或域名',
`port` INT NOT NULL DEFAULT 3389 COMMENT 'RDP 端口',
`username` VARCHAR(100) NOT NULL COMMENT 'RDP 用户名',
`password` TEXT NOT NULL COMMENT 'RDP 密码(Fernet 加密)',
`description` TEXT NULL COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
"DROP TABLE IF EXISTS `rdp_hosts`",
"""CREATE TABLE IF NOT EXISTS `admin_ui_preferences` (
`admin_id` INT NOT NULL COMMENT '管理员 ID',
`context` VARCHAR(50) NOT NULL COMMENT 'UI 上下文',
`payload` JSON NOT NULL COMMENT 'JSON 状态',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uq_rdp_hosts_name` (`name`)
PRIMARY KEY (`admin_id`, `context`),
CONSTRAINT `fk_admin_ui_pref_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
]
async with AsyncSessionLocal() as session:
@@ -1,46 +0,0 @@
"""RDP host repository (standalone 3389 endpoints)."""
from __future__ import annotations
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import RdpHost
class RdpHostRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, host_id: int) -> Optional[RdpHost]:
result = await self.session.execute(select(RdpHost).where(RdpHost.id == host_id))
return result.scalar_one_or_none()
async def get_by_name(self, name: str) -> Optional[RdpHost]:
result = await self.session.execute(select(RdpHost).where(RdpHost.name == name))
return result.scalar_one_or_none()
async def list_all(self) -> List[RdpHost]:
result = await self.session.execute(select(RdpHost).order_by(RdpHost.name))
return list(result.scalars().all())
async def create(self, row: RdpHost) -> RdpHost:
self.session.add(row)
await self.session.commit()
await self.session.refresh(row)
return row
async def update(self, row: RdpHost) -> RdpHost:
await self.session.commit()
await self.session.refresh(row)
return row
async def delete(self, host_id: int) -> bool:
row = await self.get_by_id(host_id)
if not row:
return False
await self.session.delete(row)
await self.session.commit()
return True
@@ -1 +0,0 @@
"""Guacamole protocol bridge (guacd)."""
-164
View File
@@ -1,164 +0,0 @@
"""Guacamole protocol helpers (instruction encode/decode + guacd handshake)."""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Optional
logger = logging.getLogger("nexus.guacamole.protocol")
def encode_element(value: str) -> str:
s = value if value is not None else ""
return f"{len(s)}.{s}"
def encode_instruction(opcode: str, *args: str) -> str:
parts = [encode_element(opcode)] + [encode_element(a) for a in args]
return ",".join(parts) + ";"
def parse_instruction(message: str) -> tuple[str, list[str]]:
"""Parse one complete instruction (without trailing semicolon)."""
elements: list[str] = []
i = 0
length = len(message)
while i < length:
dot = message.find(".", i)
if dot < 0:
raise ValueError(f"invalid guacamole instruction at {i}")
el_len = int(message[i:dot])
start = dot + 1
end = start + el_len
elements.append(message[start:end])
i = end
if i < length and message[i] == ",":
i += 1
else:
break
if not elements:
raise ValueError("empty guacamole instruction")
return elements[0], elements[1:]
class GuacdStream:
"""Buffered guacd TCP reader — one instruction per read()."""
def __init__(self, reader: asyncio.StreamReader):
self._reader = reader
self._buf = b""
async def read_instruction(self) -> tuple[str, list[str]]:
while True:
semi = self._buf.find(b";")
if semi >= 0:
raw = self._buf[:semi]
self._buf = self._buf[semi + 1 :]
msg = raw.decode("utf-8", errors="replace")
return parse_instruction(msg)
chunk = await self._reader.read(8192)
if not chunk:
raise ConnectionError("guacd closed during read")
self._buf += chunk
async def read_raw_instruction(self) -> bytes:
"""Read one complete guacd instruction (including trailing ``;``)."""
while True:
semi = self._buf.find(b";")
if semi >= 0:
raw = self._buf[: semi + 1]
self._buf = self._buf[semi + 1 :]
return raw
chunk = await self._reader.read(8192)
if not chunk:
raise ConnectionError("guacd closed during read")
self._buf += chunk
async def read_raw_batch(self) -> bytes:
"""Read all currently buffered complete instructions (may be multiple)."""
parts: list[bytes] = []
while True:
semi = self._buf.find(b";")
if semi < 0:
if not parts:
chunk = await self._reader.read(8192)
if not chunk:
if parts:
break
raise ConnectionError("guacd closed during read")
self._buf += chunk
continue
break
parts.append(await self.read_raw_instruction())
return b"".join(parts)
def _param_for_arg(arg_name: str, params: dict[str, str]) -> str:
"""Map guacd arg name to connection parameter value."""
normalized = arg_name.replace("_", "-").lower()
for key, value in params.items():
if key.replace("_", "-").lower() == normalized:
return value or ""
return ""
async def guacd_rdp_handshake(
stream: GuacdStream,
writer: asyncio.StreamWriter,
params: dict[str, str],
*,
width: int = 1024,
height: int = 768,
dpi: int = 96,
) -> str:
"""Run guacd handshake for RDP; return connection id from ``ready``."""
writer.write(encode_instruction("select", "rdp").encode("utf-8"))
await writer.drain()
arg_names: list[str] = []
while True:
opcode, args = await stream.read_instruction()
if opcode == "args":
arg_names = list(args)
break
if opcode == "error":
raise RuntimeError(args[0] if args else "guacd error during args")
logger.warning("guacd unexpected during select: %s %s", opcode, args)
writer.write(encode_instruction("size", str(width), str(height), str(dpi)).encode("utf-8"))
await writer.drain()
writer.write(encode_instruction("audio").encode("utf-8"))
await writer.drain()
writer.write(encode_instruction("video").encode("utf-8"))
await writer.drain()
connect_values = [_param_for_arg(name, params) for name in arg_names]
writer.write(encode_instruction("connect", *connect_values).encode("utf-8"))
await writer.drain()
while True:
opcode, args = await stream.read_instruction()
if opcode == "ready":
if not args:
raise RuntimeError("guacd ready without connection id")
return args[0]
if opcode == "error":
raise RuntimeError(args[0] if args else "guacd connection failed")
logger.debug("guacd during handshake: %s %s", opcode, args)
def tunnel_open_instruction(connection_id: Optional[str] = None) -> str:
"""Empty opcode + UUID — tells guacamole-common-js the tunnel is OPEN."""
cid = connection_id or str(uuid.uuid4())
return encode_instruction("", cid)
def is_internal_instruction(message: str) -> bool:
"""True if message uses internal (empty) opcode — not for guacd."""
try:
opcode, _ = parse_instruction(message.rstrip(";"))
return opcode == ""
except ValueError:
return False
-58
View File
@@ -1,58 +0,0 @@
"""Async WebSocket ↔ guacd TCP bridge (Guacamole protocol relay)."""
from __future__ import annotations
import asyncio
import logging
from fastapi import WebSocket, WebSocketDisconnect
logger = logging.getLogger("nexus.guacamole.tunnel")
async def bridge_websocket_to_guacd(
websocket: WebSocket,
guacd_host: str,
guacd_port: int,
) -> None:
"""Relay Guacamole instructions between browser and guacd."""
reader: asyncio.StreamReader
writer: asyncio.StreamWriter
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
async def ws_to_guacd() -> None:
try:
while True:
data = await websocket.receive_text()
writer.write(data.encode("utf-8"))
await writer.drain()
except WebSocketDisconnect:
pass
except Exception as exc:
logger.debug("RDP WS→guacd ended: %s", exc)
async def guacd_to_ws() -> None:
try:
while True:
chunk = await reader.read(8192)
if not chunk:
break
await websocket.send_text(chunk.decode("utf-8", errors="replace"))
except Exception as exc:
logger.debug("RDP guacd→WS ended: %s", exc)
ws_task = asyncio.create_task(ws_to_guacd())
guacd_task = asyncio.create_task(guacd_to_ws())
try:
await asyncio.wait(
[ws_task, guacd_task],
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for task in (ws_task, guacd_task):
task.cancel()
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
@@ -1,87 +0,0 @@
"""Guacamole WebSocket tunnel: handshake with guacd then relay (not raw TCP bridge)."""
from __future__ import annotations
import asyncio
import logging
from fastapi import WebSocket, WebSocketDisconnect
from server.infrastructure.guacamole.protocol import (
GuacdStream,
guacd_rdp_handshake,
is_internal_instruction,
tunnel_open_instruction,
)
logger = logging.getLogger("nexus.guacamole.ws_tunnel")
async def run_guacamole_rdp_tunnel(
websocket: WebSocket,
guacd_host: str,
guacd_port: int,
connect_params: dict[str, str],
*,
width: int = 1024,
height: int = 768,
) -> None:
"""Handshake with guacd using server-side credentials, then bridge WS ↔ guacd."""
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
stream = GuacdStream(reader)
try:
conn_id = await guacd_rdp_handshake(
stream, writer, connect_params, width=width, height=height,
)
await websocket.send_text(tunnel_open_instruction(conn_id))
except Exception as exc:
logger.warning("guacd handshake failed: %s", exc)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
await websocket.close(code=1011, reason=str(exc)[:120])
return
async def ws_to_guacd() -> None:
try:
while True:
data = await websocket.receive_text()
if is_internal_instruction(data):
# Tunnel stability pings use empty opcode — echo, do not forward to guacd.
await websocket.send_text(data)
continue
writer.write(data.encode("utf-8"))
await writer.drain()
except WebSocketDisconnect:
pass
except Exception as exc:
logger.debug("RDP WS→guacd ended: %s", exc)
async def guacd_to_ws() -> None:
try:
while True:
# Must not split mid-instruction — guacamole-common-js parses per WS message.
batch = await stream.read_raw_batch()
if not batch:
break
await websocket.send_text(batch.decode("utf-8", errors="replace"))
except ConnectionError:
pass
except Exception as exc:
logger.debug("RDP guacd→WS ended: %s", exc)
ws_task = asyncio.create_task(ws_to_guacd())
guacd_task = asyncio.create_task(guacd_to_ws())
try:
await asyncio.wait([ws_task, guacd_task], return_when=asyncio.FIRST_COMPLETED)
finally:
for task in (ws_task, guacd_task):
task.cancel()
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
@@ -38,6 +38,21 @@ async def publish_setting_change(db_key: str, value: str) -> bool:
return False
async def publish_setting_changes(changes: dict[str, str]) -> int:
"""Apply in-memory overrides and broadcast each key to all workers.
Returns the number of keys successfully published.
"""
from server.config import settings
published = 0
for db_key, value in changes.items():
if settings.apply_db_override(db_key, value):
if await publish_setting_change(db_key, value):
published += 1
return published
async def start_settings_subscriber() -> None:
"""Subscribe to nexus:settings — called during app startup (every worker)."""
global _subscriber_task, _redis_subscriber
@@ -33,17 +33,17 @@ async def probe_files_capability(server: Server) -> dict:
whitelist_probe = await exec_ssh_command(
server,
"sudo -n bash -c 'command -v chmod >/dev/null && command -v chown >/dev/null'",
"sudo -n bash -c 'command -v chmod >/dev/null && command -v chown >/dev/null && command -v rsync >/dev/null'",
timeout=15,
)
whitelist_ok = can_sudo_nopasswd and whitelist_probe.get("exit_code") == 0
if can_sudo_nopasswd and whitelist_ok:
message = "已配置免密 sudo,文件管理可自动提权"
message = "已配置免密 sudo,文件管理与推送可自动提权"
elif can_sudo_nopasswd:
message = (
"可免密 sudo,但 chmod/chown 可能未列入白名单;"
"请参考仓库内 sudoers 示例补全"
"可免密 sudo,但 chmod/chown/rsync 可能未列入白名单;"
"文件推送至 www 目录会失败,请参考仓库内 sudoers 示例补全"
)
else:
message = (
+5 -6
View File
@@ -67,8 +67,6 @@ from server.api.websocket import router as websocket_router
from server.api.health import router as health_router
from server.api.assets import router as assets_router
from server.api.webssh import router as webssh_router
from server.api.rdp import router as rdp_router
from server.api.rdp_hosts import router as rdp_hosts_router
from server.api.sync_v2 import router as sync_v2_router
from server.api.files import router as files_router
from server.api.search import router as search_router
@@ -677,10 +675,6 @@ app.include_router(assets_router)
# Web SSH Terminal
app.include_router(webssh_router)
# Browser RDP (guacd tunnel + standalone hosts)
app.include_router(rdp_router)
app.include_router(rdp_hosts_router)
# Sync Engine v2 (Step 4)
app.include_router(sync_v2_router)
@@ -693,6 +687,11 @@ app.include_router(search_router)
# Terminal Quick Commands
app.include_router(terminal_router)
# Embedded browser UI state
from server.api.browser import router as browser_router # noqa: E402
app.include_router(browser_router)
# ── Custom 404: return blank HTML to hide backend identity ──
@app.exception_handler(StarletteHTTPException)
+164
View File
@@ -0,0 +1,164 @@
"""Embedded browser UI state — history, tabs, panel layout."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
BROWSER_UI_CONTEXT = "embedded_browser"
MAX_URL_HISTORY = 128
MAX_VISIT_LOG = 512
MAX_TABS = 12
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds")
def _is_safe_url(url: str) -> bool:
try:
parsed = urlparse(url.strip())
except Exception:
return False
if parsed.scheme not in ("http", "https"):
return False
if parsed.username or parsed.password:
return False
return bool(parsed.netloc)
def _hostname(url: str) -> str:
try:
return urlparse(url).hostname or url
except Exception:
return url
def empty_browser_state() -> dict[str, Any]:
return {
"url_history": [],
"visits": [],
"tabs": [],
"active_tab_id": None,
"minimized": False,
"rect": None,
"minimized_rect": None,
}
def _parse_rect(raw: dict | None, *, default_w: float, default_h: float) -> dict[str, float] | None:
if raw is None or not isinstance(raw, dict):
return None
try:
return {
"x": float(raw.get("x", 0)),
"y": float(raw.get("y", 0)),
"w": float(raw.get("w", default_w)),
"h": float(raw.get("h", default_h)),
}
except (TypeError, ValueError):
return None
def parse_browser_state(raw: dict | None) -> dict[str, Any]:
base = empty_browser_state()
if not raw or not isinstance(raw, dict):
return base
url_history = [
u for u in raw.get("url_history") or []
if isinstance(u, str) and _is_safe_url(u)
][:MAX_URL_HISTORY]
visits: list[dict[str, str]] = []
for item in raw.get("visits") or []:
if not isinstance(item, dict):
continue
url = item.get("url")
if not isinstance(url, str) or not _is_safe_url(url):
continue
visits.append({
"url": url,
"title": str(item.get("title") or _hostname(url))[:200],
"at": str(item.get("at") or _utcnow_iso()),
})
visits = visits[:MAX_VISIT_LOG]
tabs: list[dict[str, Any]] = []
for item in raw.get("tabs") or []:
if not isinstance(item, dict):
continue
tab_id = item.get("id")
url = item.get("url")
if not isinstance(tab_id, str) or not tab_id.strip():
continue
if not isinstance(url, str):
continue
if url.strip() and not _is_safe_url(url):
continue
if url.strip():
stack = [
u for u in item.get("stack") or [url]
if isinstance(u, str) and _is_safe_url(u)
] or [url]
stack_index = item.get("stack_index", len(stack) - 1)
if not isinstance(stack_index, int):
stack_index = len(stack) - 1
stack_index = max(0, min(stack_index, len(stack) - 1))
else:
stack = []
stack_index = -1
tabs.append({
"id": tab_id.strip()[:64],
"url": url.strip(),
"title": str(item.get("title") or (_hostname(url) if url.strip() else "新标签"))[:200],
"stack": stack[-64:],
"stack_index": stack_index,
})
tabs = tabs[:MAX_TABS]
active_tab_id = raw.get("active_tab_id")
if not isinstance(active_tab_id, str) or not any(t["id"] == active_tab_id for t in tabs):
active_tab_id = tabs[0]["id"] if tabs else None
rect = _parse_rect(raw.get("rect"), default_w=960, default_h=640)
minimized_rect = _parse_rect(raw.get("minimized_rect"), default_w=320, default_h=52)
minimized = bool(raw.get("minimized"))
return {
"url_history": url_history,
"visits": visits,
"tabs": tabs,
"active_tab_id": active_tab_id,
"minimized": minimized,
"rect": rect,
"minimized_rect": minimized_rect,
}
def merge_browser_state(current: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
"""Merge client state; incoming wins for tabs/active/minimized/rect."""
parsed = parse_browser_state({**current, **incoming})
if incoming.get("url_history") is not None:
parsed["url_history"] = parse_browser_state({"url_history": incoming["url_history"]})["url_history"]
if incoming.get("visits") is not None:
parsed["visits"] = parse_browser_state({"visits": incoming["visits"]})["visits"]
return parsed
def record_visit(state: dict[str, Any], url: str, title: str | None = None) -> dict[str, Any]:
if not _is_safe_url(url):
return state
next_state = parse_browser_state(state)
hist = [url] + [u for u in next_state["url_history"] if u != url]
next_state["url_history"] = hist[:MAX_URL_HISTORY]
visit = {
"url": url,
"title": (title or _hostname(url))[:200],
"at": _utcnow_iso(),
}
visits = [visit] + next_state["visits"]
next_state["visits"] = visits[:MAX_VISIT_LOG]
return next_state
+104
View File
@@ -0,0 +1,104 @@
"""Post-upload permission fixup for file manager (reuse rsync push defaults)."""
from __future__ import annotations
import logging
import re
import shlex
from typing import TYPE_CHECKING
from server.application.services.sync_engine_v2 import _RSYNC_CHMOD_RE, _RSYNC_CHOWN_RE
if TYPE_CHECKING:
from server.domain.models import Server
logger = logging.getLogger(__name__)
def parse_upload_file_mode(chmod_setting: str) -> str | None:
"""Extract octal file mode from rsync-style ``D755,F755`` or plain ``644``."""
raw = (chmod_setting or "").strip()
if not raw or not _RSYNC_CHMOD_RE.fullmatch(raw):
return None
m = re.search(r"F([0-7]{3,4})", raw, re.IGNORECASE)
if m:
return m.group(1)
if re.fullmatch(r"[0-7]{3,4}", raw):
return raw
for part in reversed(raw.split(",")):
piece = part.strip()
if len(piece) > 1 and piece[0] in "Ff" and re.fullmatch(r"[0-7]{3,4}", piece[1:]):
return piece[1:]
return None
def parse_upload_chown(chown_setting: str) -> tuple[str, str] | None:
"""Return ``(owner, group)`` when setting is valid; else None."""
raw = (chown_setting or "").strip()
if not raw or not _RSYNC_CHOWN_RE.fullmatch(raw):
return None
if ":" in raw:
owner, group = raw.split(":", 1)
return owner.strip(), group.strip()
return raw, raw
def upload_permission_plan() -> tuple[tuple[str, str] | None, str | None]:
"""Read ``NEXUS_RSYNC_PUSH_CHOWN`` / ``NEXUS_RSYNC_PUSH_CHMOD`` for uploads."""
from server.config import settings
return (
parse_upload_chown(settings.RSYNC_PUSH_CHOWN or ""),
parse_upload_file_mode(settings.RSYNC_PUSH_CHMOD or ""),
)
async def apply_upload_file_permissions(server: Server, remote_path: str) -> dict:
"""chown/chmod uploaded file; mirrors rsync push defaults (www:www, F755)."""
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
chown_pair, file_mode = upload_permission_plan()
if not chown_pair and not file_mode:
return {"applied": False, "skipped": True}
path_q = shlex.quote(remote_path)
elevated = False
errors: list[str] = []
if chown_pair:
owner_spec = shlex.quote(f"{chown_pair[0]}:{chown_pair[1]}")
result = await exec_ssh_command_with_fallback(
server, f"chown {owner_spec} {path_q}", timeout=30,
)
elevated = bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chown 失败")
if file_mode and not errors:
result = await exec_ssh_command_with_fallback(
server, f"chmod {file_mode} {path_q}", timeout=30,
)
elevated = elevated or bool(result.get("elevated"))
if result.get("exit_code") != 0:
err = ((result.get("stderr") or "") + (result.get("stdout") or "")).strip()[:200]
errors.append(err or "chmod 失败")
if errors:
logger.warning(
"Upload permission fixup failed for %s on server %s: %s",
remote_path,
getattr(server, "id", "?"),
"; ".join(errors),
)
owner, group = chown_pair if chown_pair else (None, None)
return {
"applied": not errors and bool(chown_pair or file_mode),
"skipped": False,
"owner": owner,
"group": group,
"mode": file_mode,
"elevated": elevated,
"error": "; ".join(errors) if errors else None,
}
-151
View File
@@ -1,151 +0,0 @@
"""Server-level RDP settings (stored in ``servers.extra_attrs``)."""
from __future__ import annotations
from dataclasses import dataclass
from server.domain.models import Server
RDP_ENABLED_ATTR = "rdp_enabled"
RDP_PORT_ATTR = "rdp_port"
RDP_USERNAME_ATTR = "rdp_username"
RDP_PASSWORD_ATTR = "rdp_password"
DEFAULT_RDP_PORT = 3389
@dataclass(frozen=True)
class RdpConfig:
enabled: bool
port: int
username: str
password_set: bool
def _attrs(server: Server) -> dict:
raw = server.extra_attrs
return raw if isinstance(raw, dict) else {}
def normalize_rdp_port(value: object | None) -> int:
if value is None or value == "":
return DEFAULT_RDP_PORT
try:
port = int(value)
except (TypeError, ValueError):
return DEFAULT_RDP_PORT
if 1 <= port <= 65535:
return port
return DEFAULT_RDP_PORT
def get_rdp_config(server: Server) -> RdpConfig:
attrs = _attrs(server)
enabled = bool(attrs.get(RDP_ENABLED_ATTR))
port = normalize_rdp_port(attrs.get(RDP_PORT_ATTR))
username = str(attrs.get(RDP_USERNAME_ATTR) or "").strip()
password_set = bool(str(attrs.get(RDP_PASSWORD_ATTR) or "").strip())
return RdpConfig(
enabled=enabled,
port=port,
username=username,
password_set=password_set,
)
def get_rdp_password_plain(server: Server) -> str:
enc = str(_attrs(server).get(RDP_PASSWORD_ATTR) or "").strip()
if not enc:
return ""
from server.infrastructure.database.crypto import decrypt_value
try:
return decrypt_value(enc)
except Exception:
return ""
def merge_rdp_into_extra_attrs(
extra_attrs: dict | None,
*,
enabled: bool | None = None,
port: int | None = None,
username: str | None = None,
password_enc: str | None = None,
clear_password: bool = False,
) -> dict | None:
if (
enabled is None
and port is None
and username is None
and password_enc is None
and not clear_password
):
return extra_attrs
out = dict(extra_attrs or {})
if enabled is not None:
out[RDP_ENABLED_ATTR] = bool(enabled)
if port is not None:
out[RDP_PORT_ATTR] = normalize_rdp_port(port)
if username is not None:
out[RDP_USERNAME_ATTR] = username.strip()
if clear_password:
out.pop(RDP_PASSWORD_ATTR, None)
elif password_enc is not None:
out[RDP_PASSWORD_ATTR] = password_enc
return out
def apply_rdp_payload(data: dict, existing_extra: dict | None = None) -> None:
"""Pop RDP API fields from payload and merge into ``extra_attrs``."""
enabled = data.pop("rdp_enabled", None)
port = data.pop("rdp_port", None)
username = data.pop("rdp_username", None)
password = data.pop("rdp_password", None)
if enabled is None and port is None and username is None and password is None:
return
from server.infrastructure.database.crypto import encrypt_value
existing = dict(existing_extra or data.get("extra_attrs") or {})
password_enc = None
clear_password = False
if password is not None:
plain = str(password).strip()
if plain:
password_enc = encrypt_value(plain)
else:
clear_password = True
data["extra_attrs"] = merge_rdp_into_extra_attrs(
existing,
enabled=enabled,
port=port if port is not None else None,
username=username if username is not None else None,
password_enc=password_enc,
clear_password=clear_password,
)
def build_guacamole_connect_string(
hostname: str,
port: int,
username: str,
password: str,
) -> str:
"""Key=value string for guacamole-common-js ``client.connect()``."""
pairs = [
("hostname", hostname.strip()),
("port", str(normalize_rdp_port(port))),
("username", username.strip()),
("password", password),
("security", "any"),
("ignore-cert", "true"),
("disable-wallpaper", "true"),
("disable-theming", "true"),
("enable-font-smoothing", "true"),
("enable-full-window-drag", "false"),
("enable-desktop-composition", "false"),
("enable-menu-animations", "false"),
]
return "&".join(f"{k}={v}" for k, v in pairs if v != "")
+37
View File
@@ -0,0 +1,37 @@
"""Rsync remote receiver elevation — mirrors ``files_elevation`` for file push.
Policy (same as file manager): **root** SSH no sudo; **non-root** with default
``auto_sudo`` (or ``always_sudo``) ``sudo -n rsync`` on the receiver.
Only ``files_elevation=off`` disables elevation.
"""
from __future__ import annotations
from server.domain.models import Server
from server.utils.files_elevation import FilesElevation, get_files_elevation
# Fixed remote receiver command (no user input); requires NOPASSWD rsync in sudoers.
RSYNC_SUDO_RECEIVER_PATH = "sudo -n rsync"
def is_root_ssh_user(server: Server) -> bool:
ssh_user = (server.username or "root").strip() or "root"
return ssh_user == "root"
def rsync_receiver_path_for_push(server: Server) -> str | None:
"""Return ``--rsync-path`` for non-root servers when elevation is enabled."""
if is_root_ssh_user(server):
return None
if get_files_elevation(server) == FilesElevation.OFF:
return None
return RSYNC_SUDO_RECEIVER_PATH
def rsync_elevation_log_hint(server: Server) -> str | None:
"""Short tag for sync_logs.push_permission."""
if is_root_ssh_user(server):
return None
if get_files_elevation(server) == FilesElevation.OFF:
return None
return "rsync=sudo"
+57
View File
@@ -0,0 +1,57 @@
"""Server list search history — normalize and merge rules (DB payload shape)."""
from __future__ import annotations
MAX_SERVER_SEARCH_HISTORY = 12
MAX_SERVER_SEARCH_QUERY_LEN = 200
SERVERS_SEARCH_CONTEXT = "servers_search"
def normalize_search_query(query: str | None) -> str:
return (query or "").strip()[:MAX_SERVER_SEARCH_QUERY_LEN]
def empty_search_state() -> dict:
return {"history": [], "last": None}
def parse_search_state(raw: dict | None) -> dict:
if not raw:
return empty_search_state()
history_raw = raw.get("history")
history: list[str] = []
if isinstance(history_raw, list):
for item in history_raw:
if not isinstance(item, str):
continue
q = normalize_search_query(item)
if q and q not in history:
history.append(q)
if len(history) >= MAX_SERVER_SEARCH_HISTORY:
break
last_raw = raw.get("last")
last = normalize_search_query(last_raw) if isinstance(last_raw, str) else ""
last = last or None
return {"history": history, "last": last}
def apply_search_record(state: dict, query: str) -> dict:
q = normalize_search_query(query)
history = list(state.get("history") or [])
if not q:
return {"history": history, "last": None}
history = [q] + [x for x in history if x != q][:MAX_SERVER_SEARCH_HISTORY - 1]
return {"history": history, "last": q}
def apply_search_replace(*, history: list[str], last: str | None = None) -> dict:
cleaned: list[str] = []
for item in history:
q = normalize_search_query(item)
if q and q not in cleaned:
cleaned.append(q)
if len(cleaned) >= MAX_SERVER_SEARCH_HISTORY:
break
last_norm = normalize_search_query(last) if last else ""
last_val = last_norm or (cleaned[0] if cleaned else None)
return {"history": cleaned, "last": last_val}
+3 -2
View File
@@ -133,11 +133,12 @@ def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str,
"""Clone Pydantic error dicts with translated ``msg`` and ``loc_zh``."""
translated: list[dict[str, Any]] = []
for err in errors:
item = dict(err)
ctx = err.get("ctx") if isinstance(err.get("ctx"), dict) else None
item = {k: v for k, v in err.items() if k != "ctx"}
item["msg"] = translate_validation_msg(
str(err.get("msg", "")),
str(err.get("type", "")),
err.get("ctx") if isinstance(err.get("ctx"), dict) else None,
ctx,
)
item["loc_zh"] = translate_loc_zh(err.get("loc"))
translated.append(item)
+36
View File
@@ -0,0 +1,36 @@
"""Tests for embedded browser UI state."""
from server.utils.browser_ui_state import (
empty_browser_state,
parse_browser_state,
record_visit,
)
def test_empty_browser_state():
assert parse_browser_state(None) == empty_browser_state()
def test_parse_filters_unsafe_urls():
raw = {
"url_history": ["https://ok.example", "javascript:alert(1)", "ftp://x"],
"tabs": [
{"id": "t1", "url": "https://a.com", "stack": ["https://a.com"], "stack_index": 0},
{"id": "t2", "url": "data:text/html,x"},
],
}
state = parse_browser_state(raw)
assert state["url_history"] == ["https://ok.example"]
assert len(state["tabs"]) == 1
assert state["tabs"][0]["id"] == "t1"
def test_record_visit_dedupes_history():
state = empty_browser_state()
state = record_visit(state, "https://a.example", "A")
state = record_visit(state, "https://b.example", "B")
state = record_visit(state, "https://a.example", "A again")
assert state["url_history"][0] == "https://a.example"
assert state["url_history"][1] == "https://b.example"
assert len(state["visits"]) == 3
assert state["visits"][0]["title"] == "A again"
+6
View File
@@ -22,6 +22,12 @@ def test_compute_browse_etag_stable():
assert len(e1) > 10
def test_compute_browse_etag_changes_when_permissions_change():
base = [{"name": "a.txt", "modified": "2026-01-01", "size": 10, "permissions": "-rw-r--r--", "owner": "www", "group": "www", "mode_octal": "644"}]
changed = [{**base[0], "permissions": "-rwxr-xr-x", "mode_octal": "755"}]
assert compute_browse_etag("/var/www", base) != compute_browse_etag("/var/www", changed)
def test_browse_server_cache_304_path():
payload = {"path": "/tmp", "items": [{"name": "x", "modified": "t", "size": 1}]}
etag = compute_browse_etag("/tmp", payload["items"])
+90
View File
@@ -0,0 +1,90 @@
"""Tests for post-upload permission fixup."""
import pytest
from server.utils.files_upload_permissions import (
apply_upload_file_permissions,
parse_upload_chown,
parse_upload_file_mode,
upload_permission_plan,
)
from server.config import settings
def test_parse_upload_file_mode_from_rsync_default():
assert parse_upload_file_mode("D755,F755") == "755"
assert parse_upload_file_mode("F644") == "644"
assert parse_upload_file_mode("755") == "755"
def test_parse_upload_file_mode_invalid():
assert parse_upload_file_mode("") is None
assert parse_upload_file_mode("bad") is None
def test_parse_upload_chown():
assert parse_upload_chown("www:www") == ("www", "www")
assert parse_upload_chown("root") == ("root", "root")
assert parse_upload_chown("www;evil") is None
def test_upload_permission_plan_defaults():
owner_group, mode = upload_permission_plan()
assert owner_group == ("www", "www")
assert mode == "755"
def test_upload_permission_plan_disabled(monkeypatch):
monkeypatch.setattr(settings, "RSYNC_PUSH_CHOWN", "")
monkeypatch.setattr(settings, "RSYNC_PUSH_CHMOD", "")
assert upload_permission_plan() == (None, None)
@pytest.mark.asyncio
async def test_apply_upload_file_permissions_success(monkeypatch):
calls: list[tuple[str, str]] = []
async def fake_exec(server, cmd, timeout=30):
calls.append((getattr(server, "id", "?"), cmd))
return {"exit_code": 0, "elevated": True}
monkeypatch.setattr(
"server.infrastructure.ssh.remote_shell.exec_ssh_command_with_fallback",
fake_exec,
)
class FakeServer:
id = 1
result = await apply_upload_file_permissions(FakeServer(), "/var/www/test.txt")
assert result["applied"] is True
assert result["owner"] == "www"
assert result["group"] == "www"
assert result["mode"] == "755"
assert result["elevated"] is True
assert result["error"] is None
assert len(calls) == 2
assert "chown" in calls[0][1] and "www:www" in calls[0][1]
assert "chmod 755" in calls[1][1]
@pytest.mark.asyncio
async def test_apply_upload_file_permissions_chown_failure(monkeypatch):
async def fake_exec(server, cmd, timeout=30):
if cmd.startswith("chown"):
return {"exit_code": 1, "stderr": "permission denied", "elevated": False}
return {"exit_code": 0, "elevated": False}
monkeypatch.setattr(
"server.infrastructure.ssh.remote_shell.exec_ssh_command_with_fallback",
fake_exec,
)
class FakeServer:
id = 2
result = await apply_upload_file_permissions(FakeServer(), "/tmp/x")
assert result["applied"] is False
assert "permission denied" in (result["error"] or "")
-56
View File
@@ -1,56 +0,0 @@
"""Guacamole protocol encode/decode and internal opcode detection."""
import asyncio
import pytest
from server.infrastructure.guacamole.protocol import (
GuacdStream,
encode_instruction,
is_internal_instruction,
parse_instruction,
tunnel_open_instruction,
)
def test_encode_instruction():
assert encode_instruction("select", "rdp") == "6.select,3.rdp;"
def test_parse_instruction_roundtrip():
raw = encode_instruction("connect", "host", "3389").rstrip(";")
opcode, args = parse_instruction(raw)
assert opcode == "connect"
assert args == ["host", "3389"]
def test_tunnel_open_instruction():
msg = tunnel_open_instruction("abc-uuid")
assert is_internal_instruction(msg)
opcode, args = parse_instruction(msg.rstrip(";"))
assert opcode == ""
assert args == ["abc-uuid"]
def test_is_internal_ping():
ping = encode_instruction("", "ping", "12345")
assert is_internal_instruction(ping)
@pytest.mark.asyncio
async def test_guacd_stream_splits_tcp_chunks_on_semicolon():
class FakeReader:
def __init__(self, chunks: list[bytes]):
self._chunks = list(chunks)
async def read(self, n: int) -> bytes:
if not self._chunks:
return b""
return self._chunks.pop(0)
ins = encode_instruction("sync", "123").encode("utf-8")
reader = FakeReader([ins[:10], ins[10:]])
stream = GuacdStream(reader) # type: ignore[arg-type]
raw = await stream.read_raw_instruction()
assert raw == ins
assert raw.endswith(b";")
+93
View File
@@ -0,0 +1,93 @@
"""Tests for login IP allowlist refresh and cross-worker settings broadcast."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from server.background.ip_allowlist_refresh import RefreshResult, do_refresh
from server.config import settings
from server.infrastructure.settings_broadcast import publish_setting_changes
@pytest.mark.asyncio
async def test_publish_setting_changes_broadcasts_each_key():
mock_redis = AsyncMock()
mock_redis.publish = AsyncMock(return_value=1)
with patch("server.infrastructure.redis.client.get_redis", return_value=mock_redis):
count = await publish_setting_changes({
"login_manual_ips": "1.2.3.4",
"login_subscription_url": "https://example.com/sub",
})
assert count == 2
assert mock_redis.publish.await_count == 2
assert settings.LOGIN_MANUAL_IPS == "1.2.3.4"
assert settings.LOGIN_SUBSCRIPTION_URL == "https://example.com/sub"
@pytest.mark.asyncio
async def test_do_refresh_publishes_subscription_ips():
settings.LOGIN_SUBSCRIPTION_URL = "https://example.com/sub"
settings.LOGIN_MANUAL_IPS = "10.0.0.1"
settings.LOGIN_SUBSCRIPTION_IPS = ""
settings.LOGIN_ALLOWED_IPS = ""
mock_repo = MagicMock()
mock_repo.set = AsyncMock()
mock_session = MagicMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=None)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "ss://YWVzLTEyODpwbG4@104.194.72.62:443#node1"
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with (
patch("server.infrastructure.database.session.AsyncSessionLocal", return_value=mock_session),
patch("server.infrastructure.database.setting_repo.SettingRepositoryImpl", return_value=mock_repo),
patch("server.infrastructure.settings_broadcast.publish_setting_changes", new_callable=AsyncMock) as mock_publish,
patch("httpx.AsyncClient", return_value=mock_client),
):
result = await do_refresh()
assert result.ok is True
assert result.subscription_ips == ("104.194.72.62",)
assert result.subscription_count == 1
assert settings.LOGIN_SUBSCRIPTION_IPS == "104.194.72.62"
mock_publish.assert_awaited_once()
published = mock_publish.await_args.args[0]
assert published["login_subscription_ips"] == "104.194.72.62"
assert "10.0.0.1" in published["login_allowed_ips"]
@pytest.mark.asyncio
async def test_do_refresh_without_subscription_url():
settings.LOGIN_SUBSCRIPTION_URL = ""
result = await do_refresh()
assert result.ok is True
assert result.subscription_ips == ()
@pytest.mark.asyncio
async def test_do_refresh_http_error():
settings.LOGIN_SUBSCRIPTION_URL = "https://example.com/sub"
mock_response = MagicMock()
mock_response.status_code = 503
mock_client = MagicMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("httpx.AsyncClient", return_value=mock_client):
result = await do_refresh()
assert result.ok is False
assert result.error is not None
assert "503" in result.error
+17
View File
@@ -0,0 +1,17 @@
"""Tests for nexus-files sudoers template and batch patch helpers."""
from server.application.services.files_sudoers_service import build_nexus_files_sudoers
def test_build_nexus_files_sudoers_includes_rsync():
content = build_nexus_files_sudoers("ubuntu")
assert "ubuntu ALL=(root) NOPASSWD:" in content
assert "/usr/bin/rsync" in content
assert "/bin/rsync" in content
assert "/usr/bin/chmod" in content
def test_build_nexus_files_sudoers_strips_user():
content = build_nexus_files_sudoers(" deploy ")
assert content.startswith("# nexus-files:")
assert "deploy ALL=(root)" in content
-55
View File
@@ -1,55 +0,0 @@
"""RDP browser (guacd) configuration helpers."""
from server.domain.models import Server
from server.infrastructure.database.crypto import encrypt_value
from server.utils.rdp_config import (
apply_rdp_payload,
build_guacamole_connect_string,
get_rdp_config,
get_rdp_password_plain,
)
def test_get_rdp_config_defaults():
server = Server(name="win", domain="10.0.0.1", port=22, username="root")
cfg = get_rdp_config(server)
assert cfg.enabled is False
assert cfg.port == 3389
assert cfg.username == ""
assert cfg.password_set is False
def test_apply_rdp_payload_merges_extra_attrs():
payload = {
"rdp_enabled": True,
"rdp_port": 3390,
"rdp_username": "Administrator",
"rdp_password": "Secret123",
}
apply_rdp_payload(payload, existing_extra={"files_elevation": "auto_sudo"})
assert payload["extra_attrs"]["rdp_enabled"] is True
assert payload["extra_attrs"]["rdp_port"] == 3390
assert payload["extra_attrs"]["rdp_username"] == "Administrator"
assert payload["extra_attrs"]["rdp_password"]
assert payload["extra_attrs"]["files_elevation"] == "auto_sudo"
assert "rdp_password" not in payload
def test_get_rdp_password_plain_roundtrip():
server = Server(
name="win",
domain="10.0.0.2",
port=22,
username="root",
extra_attrs={"rdp_password": encrypt_value("pw")},
)
assert get_rdp_password_plain(server) == "pw"
def test_build_guacamole_connect_string():
s = build_guacamole_connect_string("192.168.1.10", 3389, "Admin", "pw")
assert "hostname=192.168.1.10" in s
assert "port=3389" in s
assert "username=Admin" in s
assert "password=pw" in s
assert "ignore-cert=true" in s
-24
View File
@@ -1,24 +0,0 @@
"""Standalone RDP host model helpers."""
from server.domain.models import RdpHost
from server.infrastructure.database.crypto import decrypt_value, encrypt_value
from server.utils.rdp_config import normalize_rdp_port
def test_rdp_host_model_fields():
row = RdpHost(
name="win-bj",
host="10.0.0.5",
port=3389,
username="Administrator",
password=encrypt_value("Secret"),
)
assert row.name == "win-bj"
assert row.port == 3389
assert decrypt_value(row.password) == "Secret"
def test_normalize_rdp_port():
assert normalize_rdp_port(None) == 3389
assert normalize_rdp_port(3390) == 3390
assert normalize_rdp_port(99999) == 3389

Some files were not shown because too many files have changed in this diff Show More