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) # 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'; 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/ { location /api/ {
proxy_pass http://nexus_api; proxy_pass http://nexus_api;
@@ -21,8 +22,8 @@ location /api/ {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; proxy_read_timeout 600s;
proxy_send_timeout 300s; proxy_send_timeout 600s;
proxy_connect_timeout 10s; 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":"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":"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: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 # Upload limit
client_max_body_size 100m; client_max_body_size 500m;
# Well-known for SSL cert validation # Well-known for SSL cert validation
location ~ \\.well-known { location ~ \\.well-known {
+1 -1
View File
@@ -75,5 +75,5 @@ server {
error_log {{LOG_DIR}}/{{SERVER_NAME}}_nexus.error.log; error_log {{LOG_DIR}}/{{SERVER_NAME}}_nexus.error.log;
# ── Upload limit ── # ── 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; error_log {{LOG_DIR}}/{{SERVER_NAME}}.error.log;
# ── Upload limit ── # ── Upload limit ──
client_max_body_size 100m; client_max_body_size 500m;
} }
-8
View File
@@ -40,10 +40,6 @@ services:
ports: ports:
- "${REDIS_PUBLISH_PORT:-6379}:6379" - "${REDIS_PUBLISH_PORT:-6379}:6379"
guacd:
image: guacamole/guacd:1.5.5
restart: unless-stopped
nexus: nexus:
build: build:
context: . context: .
@@ -54,8 +50,6 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
guacd:
condition: service_started
ports: ports:
- "${NEXUS_PUBLISH_PORT:-8600}:8600" - "${NEXUS_PUBLISH_PORT:-8600}:8600"
environment: environment:
@@ -75,8 +69,6 @@ services:
NEXUS_API_KEY: ${NEXUS_API_KEY:-} NEXUS_API_KEY: ${NEXUS_API_KEY:-}
NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:-} NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:-}
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:-http://localhost:8600} 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: env_file:
- path: docker/.env - path: docker/.env
required: false required: false
-10
View File
@@ -11,15 +11,7 @@
name: nexus-prod name: nexus-prod
services: services:
guacd:
image: guacamole/guacd:1.5.5
restart: unless-stopped
networks:
- nexus-internal
nexus: nexus:
depends_on:
- guacd
build: build:
context: .. context: ..
dockerfile: Dockerfile.prod dockerfile: Dockerfile.prod
@@ -46,8 +38,6 @@ services:
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:?set NEXUS_API_BASE_URL} NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:?set NEXUS_API_BASE_URL}
NEXUS_DB_POOL_SIZE: ${NEXUS_DB_POOL_SIZE:-30} NEXUS_DB_POOL_SIZE: ${NEXUS_DB_POOL_SIZE:-30}
NEXUS_DB_MAX_OVERFLOW: ${NEXUS_DB_MAX_OVERFLOW:-20} NEXUS_DB_MAX_OVERFLOW: ${NEXUS_DB_MAX_OVERFLOW:-20}
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
volumes: volumes:
- nexus-state:/var/lib/nexus - nexus-state:/var/lib/nexus
- nexus-web-data:/app/web/data - 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, \ /bin/tar, /usr/bin/tar, \
/usr/bin/unzip, /usr/bin/zip, \ /usr/bin/unzip, /usr/bin/zip, \
/usr/bin/tee, /bin/cat, /usr/bin/cat, \ /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 | | `/ws/` | WebSocket`Upgrade` + `Connection upgrade`,超时 3600s |
| `/health` | 反代(或仅内网探测) | | `/health` | 反代(或仅内网探测) |
| `/app/` | 反代(容器内 StaticFiles | | `/app/` | 反代(容器内 StaticFiles |
| `client_max_body_size` | `100m`(上传/推送) | | `client_max_body_size` | `500m`(上传/推送) |
**WS 日志**:勿记录 query 中的 JWT(示例配置已用 `$uri``$args`)。 **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; return 404;
} }
client_max_body_size 100m; client_max_body_size 500m;
access_log /var/log/nginx/nexus_access.log; access_log /var/log/nginx/nexus_access.log;
error_log /var/log/nginx/nexus_error.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': '审计日志', '/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). */ /** Restore session from worker refresh cookie (avoids parallel UI logins / lockout). */
export async function login(page) { export async function login(page) {
if (E2E_ACCESS_TOKEN) {
return loginWithAccessToken(page, E2E_ACCESS_TOKEN)
}
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' }) await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 }) await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
await expect(page.getByRole('button', { name: 'admin' })).toBeVisible({ timeout: 15000 }) 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/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"guacamole-common-js": "^1.5.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.30", "vue": "^3.5.30",
@@ -1344,11 +1343,6 @@
"node": ">= 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": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "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/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"guacamole-common-js": "^1.5.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.30", "vue": "^3.5.30",
+7 -3
View File
@@ -223,6 +223,8 @@
{{ snackbar.text }} {{ snackbar.text }}
</v-snackbar> </v-snackbar>
<GlobalBrowserPanel v-if="auth.isLoggedIn" :show-launcher="auth.isLoggedIn" />
</v-app> </v-app>
</template> </template>
@@ -247,6 +249,8 @@ import {
} from '@/composables/useScriptSubmitToast' } from '@/composables/useScriptSubmitToast'
import { clearCachedQueries } from '@/composables/useCachedQuery' import { clearCachedQueries } from '@/composables/useCachedQuery'
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch' 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 { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
import { api } from '@/api' import { api } from '@/api'
@@ -326,8 +330,8 @@ const { mobile } = useDisplay()
provide('nexusDrawer', drawer) provide('nexusDrawer', drawer)
const mainLayoutClass = computed(() => ({ const mainLayoutClass = computed(() => ({
'nexus-terminal-main': route.path === '/terminal' || route.path === '/rdp', 'nexus-terminal-main': route.path === '/terminal',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/rdp' && route.path !== '/login', 'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
})) }))
const scriptExecBarOffset = computed(() => { const scriptExecBarOffset = computed(() => {
@@ -343,7 +347,7 @@ const opsItems = [
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' }, { to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
{ to: '/servers', title: '服务器', icon: 'mdi-server' }, { to: '/servers', title: '服务器', icon: 'mdi-server' },
{ to: '/terminal', title: '终端', icon: 'mdi-console' }, { 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: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' }, { to: '/push', title: '推送', icon: 'mdi-upload-outline' },
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' }, { 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 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 }> { export async function downloadGet(path: string): Promise<{ blob: Blob; filename: string }> {
const res = await fetchAuthed(path, { method: 'GET' }) const res = await fetchAuthed(path, { method: 'GET' })
if (res.status === 202) { if (res.status === 202) {
@@ -239,6 +239,10 @@ const owner = ref('')
const group = ref('') const group = ref('')
const initialMode = ref('')
const initialOwner = ref('')
const initialGroup = ref('')
const capability = ref<FilesCapabilityResult | null>(null) const capability = ref<FilesCapabilityResult | null>(null)
const capabilityLoading = ref(false) const capabilityLoading = ref(false)
@@ -288,11 +292,14 @@ watch(
if (!open || !file) return if (!open || !file) return
const fileOwner = file.owner || ''
const fileGroup = file.group || ''
mode.value = file.mode_octal || '' mode.value = file.mode_octal || ''
owner.value = fileOwner
owner.value = file.owner || '' group.value = fileGroup && fileGroup !== fileOwner ? fileGroup : ''
initialMode.value = mode.value
group.value = file.group && file.group !== file.owner ? file.group : '' initialOwner.value = fileOwner
initialGroup.value = fileGroup
recursive.value = false recursive.value = false
capability.value = null capability.value = null
@@ -427,6 +434,10 @@ function submit() {
const o = owner.value.trim() const o = owner.value.trim()
const g = group.value.trim() const g = group.value.trim()
const rec = isDirectory.value && recursive.value 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) { if (rec) {
const name = props.file?.name ?? '该目录' const name = props.file?.name ?? '该目录'
const ok = window.confirm( const ok = window.confirm(
@@ -436,8 +447,8 @@ function submit() {
} }
emit('apply', { emit('apply', {
mode: m, mode: m,
owner: o || undefined, owner: ownerChanged || groupChanged ? (o || undefined) : undefined,
group: g || undefined, group: groupChanged ? (g || undefined) : undefined,
recursive: rec || 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) for (const f of uploadFiles.value) form.append('file', f)
form.append('server_id', String(selectedServer.value)) form.append('server_id', String(selectedServer.value))
form.append('remote_path', currentPath.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 showUpload.value = false
uploadFiles.value = [] uploadFiles.value = []
options.invalidateAfterMutation() options.invalidateAfterMutation()
await options.browse({ force: true }) await options.browse({ force: true })
snackbar('上传成功') const pf = res?.permission_fixup
if (pf?.error) {
snackbar(`上传成功,但权限修正失败: ${pf.error}`, 'warning')
} else {
snackbar('上传成功')
}
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上传失败' const msg = e instanceof Error ? e.message : '上传失败'
snackbar(msg, 'error') 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 */ /** 可拖动浮动面板位置与尺寸(持久化到 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)) const rect = ref<FloatRect>(loadRect(storageKey, fallback))
let dragOrigin = { px: 0, py: 0, rx: 0, ry: 0 } let dragOrigin = { px: 0, py: 0, rx: 0, ry: 0 }
@@ -51,18 +71,18 @@ export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
function clampToViewport() { function clampToViewport() {
const margin = 8 const margin = 8
const maxW = Math.min(window.innerWidth - margin * 2, 1400) const maxW = Math.min(window.innerWidth - margin * 2, maxWCap)
const maxH = Math.min(window.innerHeight - margin * 2, 900) const maxH = Math.min(window.innerHeight - margin * 2, maxHCap)
rect.value.w = Math.max(480, Math.min(rect.value.w, maxW)) rect.value.w = Math.max(minW, Math.min(rect.value.w, maxW))
rect.value.h = Math.max(320, Math.min(rect.value.h, maxH)) 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.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)) rect.value.y = Math.max(margin, Math.min(rect.value.y, window.innerHeight - rect.value.h - margin))
} }
function onDragMove(e: MouseEvent) { function onDragMove(e: MouseEvent) {
if (resizing) { if (resizing) {
rect.value.w = Math.max(480, resizeOrigin.rw + e.clientX - resizeOrigin.px) rect.value.w = Math.max(minW, resizeOrigin.rw + e.clientX - resizeOrigin.px)
rect.value.h = Math.max(320, resizeOrigin.rh + e.clientY - resizeOrigin.py) rect.value.h = Math.max(minH, resizeOrigin.rh + e.clientY - resizeOrigin.py)
clampToViewport() clampToViewport()
return return
} }
@@ -80,6 +100,7 @@ export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
function startDrag(e: MouseEvent) { function startDrag(e: MouseEvent) {
if (e.button !== 0) return if (e.button !== 0) return
e.preventDefault()
dragOrigin = { px: e.clientX, py: e.clientY, rx: rect.value.x, ry: rect.value.y } dragOrigin = { px: e.clientX, py: e.clientY, rx: rect.value.x, ry: rect.value.y }
window.addEventListener('mousemove', onDragMove) window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', onDragEnd) 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'), '/': () => import('@/pages/DashboardPage.vue'),
'/servers': () => import('@/pages/ServersPage.vue'), '/servers': () => import('@/pages/ServersPage.vue'),
'/terminal': () => import('@/pages/TerminalPage.vue'), '/terminal': () => import('@/pages/TerminalPage.vue'),
'/rdp': () => import('@/pages/RdpPage.vue'),
'/files': () => import('@/pages/FilesPage.vue'), '/files': () => import('@/pages/FilesPage.vue'),
'/push': () => import('@/pages/PushPage.vue'), '/push': () => import('@/pages/PushPage.vue'),
'/scripts': () => import('@/pages/ScriptsPage.vue'), '/scripts': () => import('@/pages/ScriptsPage.vue'),
@@ -30,7 +30,7 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
const sortBy = ref<TableSortItem[]>([]) const sortBy = ref<TableSortItem[]>([])
function listQueryParams(): Record<string, unknown> { function listQueryParams(): Record<string, unknown> {
const q = search.value.trim() const q = String(search.value ?? '').trim()
// 有搜索词时全局检索:忽略分类/在线/路径分区,避免刚添加的机器搜不到 // 有搜索词时全局检索:忽略分类/在线/路径分区,避免刚添加的机器搜不到
const applyPathFilter = targetPathUnset !== undefined && !q const applyPathFilter = targetPathUnset !== undefined && !q
return { 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-card-title class="d-flex align-center">
服务器列表 服务器列表
<v-spacer /> <v-spacer />
<v-text-field <v-combobox
v-model="search" v-model="searchDraft"
:items="serverSearchHistory"
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
label="搜索名称/IP(含未设路径)" label="搜索名称/IP(含未设路径)"
density="compact" density="compact"
hide-details hide-details
rounded rounded
clearable
:auto-select-first="false"
style="max-width: 240px" style="max-width: 240px"
variant="outlined" 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"> <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 }"> <template #item.actions="{ item }">
<div class="d-flex ga-1"> <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 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" @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" :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> <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 { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar' import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination' import { useServerPagination } from '@/composables/useServerPagination'
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories' import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status' import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import { fetchPagePerPage } from '@/utils/paginatedFetch' 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 type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds } from '@/utils/serverSelection' import { normalizeServerIds } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError' import { formatApiError } from '@/utils/apiError'
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue' import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast' import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
import StatCardsRow from '@/components/StatCardsRow.vue' import StatCardsRow from '@/components/StatCardsRow.vue'
@@ -721,7 +738,6 @@ onMounted(() => {
let offBatchComplete: (() => void) | null = null let offBatchComplete: (() => void) | null = null
onBeforeUnmount(() => { onBeforeUnmount(() => {
offBatchComplete?.() offBatchComplete?.()
clearTimeout(searchDebounceTimer)
}) })
defineOptions({ name: 'ServersPage' }) defineOptions({ name: 'ServersPage' })
@@ -730,6 +746,7 @@ const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
const snackbar = useSnackbar() const snackbar = useSnackbar()
const router = useRouter() const router = useRouter()
const globalBrowser = useGlobalBrowser()
const route = useRoute() const route = useRoute()
const showCredentialsDialog = ref(false) const showCredentialsDialog = ref(false)
@@ -752,15 +769,46 @@ const {
loadAllServers, listQueryParams, loadAllServers, listQueryParams,
} = useServerPagination({ targetPathUnset: false }) } = useServerPagination({ targetPathUnset: false })
let searchDebounceTimer: ReturnType<typeof setTimeout> const { items: serverSearchHistory, record: recordServerSearch, loadHistoryOnly: loadServerSearchHistory } =
function onSearch() { useServerSearchHistory()
clearTimeout(searchDebounceTimer)
searchDebounceTimer = setTimeout(() => { /** 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 page.value = 1
unsetPathPage.value = 1 unsetPathPage.value = 1
void recordServerSearch('')
void loadServers() void loadServers()
void loadPendingServers(true) 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[]>([]) const unsetPathServers = ref<ServerApiItem[]>([])
@@ -1037,7 +1085,7 @@ watch(search, () => {
watch( watch(
() => [search.value, total.value, unsetPathTotal.value, pendingServers.value.length, loading.value] as const, () => [search.value, total.value, unsetPathTotal.value, pendingServers.value.length, loading.value] as const,
([q, mainTotal, unsetTotal, pendingCount, isLoading]) => { ([q, mainTotal, unsetTotal, pendingCount, isLoading]) => {
if (!q.trim() || isLoading) return if (!String(q ?? '').trim() || isLoading) return
if (mainTotal > 0 || unsetTotal > 0) return if (mainTotal > 0 || unsetTotal > 0) return
if (pendingCount <= 0) return if (pendingCount <= 0) return
void nextTick(() => { void nextTick(() => {
@@ -1093,7 +1141,7 @@ function showFailureDialog(errors: PollErrorItem[], message?: string) {
async function loadPendingServers(silent = false) { async function loadPendingServers(silent = false) {
if (!silent) pendingLoading.value = true if (!silent) pendingLoading.value = true
try { try {
const q = search.value.trim() const q = String(search.value ?? '').trim()
const res = await http.get<{ items: PendingServerItem[]; total: number }>( const res = await http.get<{ items: PendingServerItem[]; total: number }>(
'/servers/pending', '/servers/pending',
q ? { search: q } : undefined, q ? { search: q } : undefined,
@@ -1361,6 +1409,14 @@ async function refreshStatCards() {
function openTerminal(item: ServerApiItem) { function openTerminal(item: ServerApiItem) {
router.push({ path: '/terminal', query: { server_id: String(item.id) } }) 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) { function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } }) router.push({ path: '/files', query: { server_id: String(item.id) } })
} }
@@ -1686,6 +1742,9 @@ async function doDelete() {
} }
async function refreshServersPage(silent = false) { async function refreshServersPage(silent = false) {
await loadServerSearchHistory()
search.value = ''
searchDraft.value = ''
await Promise.all([ await Promise.all([
loadStats(silent), loadStats(silent),
loadCategories(silent), loadCategories(silent),
+13 -3
View File
@@ -442,6 +442,7 @@ import type {
OpsPatrolSyncResponse, OpsPatrolSyncResponse,
SubscriptionParseResponse, SubscriptionParseResponse,
IpAllowlistSaveRequest, IpAllowlistSaveRequest,
IpAllowlistSaveResponse,
} from '@/types/api' } from '@/types/api'
import { NOTIFY_TOGGLE_ITEMS } from '@/types/api' import { NOTIFY_TOGGLE_ITEMS } from '@/types/api'
import { import {
@@ -958,10 +959,19 @@ async function saveAllowlist() {
subscription_url: subscriptionUrl.value.trim(), subscription_url: subscriptionUrl.value.trim(),
enabled: ipAllowlistEnabled.value, enabled: ipAllowlistEnabled.value,
} }
await http.post('/settings/ip-allowlist', payload) const res = await http.post<IpAllowlistSaveResponse>('/settings/ip-allowlist', payload)
snackbar('白名单已保存') 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 subParseDialog.value = false
await loadAllowlist()
} catch (e: unknown) { } catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error') snackbar(e instanceof Error ? e.message : '保存失败', 'error')
} finally { } finally {
+1 -1
View File
@@ -5,7 +5,7 @@ const routes = [
{ path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') }, { path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') },
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') }, { path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.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: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') }, { path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.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, etag?: string,
) { ) {
const key = cacheKey(serverId, path) const key = cacheKey(serverId, path)
const prev = fileCache.get(key)
fileCache.set(key, { fileCache.set(key, {
items, items,
at: Date.now(), at: Date.now(),
etag: etag ?? prev?.etag, etag: etag ?? undefined,
}) })
if (fileCache.size > FILES_CACHE_MAX_ENTRIES) { if (fileCache.size > FILES_CACHE_MAX_ENTRIES) {
const oldest = [...fileCache.entries()].sort((a, b) => a[1].at - b[1].at)[0] 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 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 */ /** File browse response from POST /api/sync/browse */
export interface BrowseResponse { export interface BrowseResponse {
path?: string path?: string
@@ -161,10 +171,6 @@ export interface ServerApiItem {
platform_id: number | null platform_id: number | null
node_id: number | null node_id: number | null
files_elevation?: FilesElevationMode files_elevation?: FilesElevationMode
rdp_enabled?: boolean
rdp_port?: number
rdp_username?: string
rdp_password_set?: boolean
is_online: boolean is_online: boolean
last_heartbeat: string | null last_heartbeat: string | null
agent_version: 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)$", pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)", 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") @field_validator("target_path", mode="before")
@classmethod @classmethod
def _normalize_target_path(cls, v: object) -> object: def _normalize_target_path(cls, v: object) -> object:
@@ -130,26 +110,6 @@ class ServerUpdate(BaseModel):
pattern="^(off|auto_sudo|always_sudo)$", pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)", 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") @field_validator("target_path", mode="before")
@classmethod @classmethod
def _normalize_target_path(cls, v: object) -> object: def _normalize_target_path(cls, v: object) -> object:
@@ -195,6 +155,19 @@ class BatchCategoryUpdate(BaseModel):
category: str = Field("", max_length=100, description="分类名称;留空表示清除分类") 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): class BatchCategoryResult(BaseModel):
updated: int = 0 updated: int = 0
not_found: List[int] = [] 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 ( from server.api.schemas import (
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest, ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
ServerImportResult, BatchAgentAction, AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult, ServerImportResult, BatchAgentAction, AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult,
BatchCategoryUpdate, BatchJobStarted, BatchJobStatus, BatchCategoryUpdate, BatchJobStarted, BatchJobStatus, ServerSearchHistoryUpdate,
) )
from server.application.server_batch_common import ( from server.application.server_batch_common import (
install_error_msg as _install_error_msg, install_error_msg as _install_error_msg,
@@ -289,6 +289,48 @@ async def server_categories(
return {"categories": categories, "platforms": platforms} 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) @router.get("/logs", response_model=dict)
async def get_all_sync_logs( async def get_all_sync_logs(
server_id: Optional[int] = Query(None, description="Filter by server ID"), server_id: Optional[int] = Query(None, description="Filter by server ID"),
@@ -1138,44 +1180,6 @@ async def get_server(
return server_data 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) @router.get("/{id}/metrics", response_model=dict)
async def get_server_metrics( async def get_server_metrics(
id: int, id: int,
@@ -1242,15 +1246,11 @@ async def setup_files_sudo(
admin: Admin = Depends(get_current_admin), admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service), service: ServerService = Depends(get_server_service),
): ):
"""Auto-configure passwordless sudoers for file manager on non-root servers. """Auto-configure passwordless sudoers for file manager + rsync push on non-root servers."""
from server.application.services.files_sudoers_service import (
Uses the stored SSH password to run `sudo -S` and write NEXUS_FILES_SUDOERS_PATH,
/etc/sudoers.d/nexus-files on the target server. install_nexus_files_sudoers,
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
server = await service.get_server(id) server = await service.get_server(id)
if not server: if not server:
@@ -1260,70 +1260,23 @@ async def setup_files_sudo(
if ssh_user == "root": if ssh_user == "root":
return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"} return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"}
if not server.password: outcome = await install_nexus_files_sudoers(server)
raise HTTPException( if outcome.get("action") == "already_ok":
status_code=400, return {
detail="该服务器未存储 SSH 密码,无法自动配置。请手动在目标机配置 sudoers。", "ok": True,
) "message": f"sudo -n rsync 已可用,无需重复配置({NEXUS_FILES_SUDOERS_PATH}",
}
plain_password = decrypt_value(server.password) if not outcome.get("ok"):
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:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"SSH 执行失败:{exc}", detail=outcome.get("detail") or f"sudoers 配置失败:{outcome.get('action')}",
) from exc )
finally:
if conn is not None:
await ssh_pool.release(server.id)
return { return {
"ok": True, "ok": True,
"message": ( "message": (
f"sudoers 已配置:{sudoers_path}" f"sudoers 已配置:{NEXUS_FILES_SUDOERS_PATH}"
"现在文件管理可使用 sudo -n 自动提权浏览/操作文件" "文件管理与文件推送可使用 sudo -n 自动提权。"
), ),
} }
@@ -1342,11 +1295,9 @@ async def create_server(
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
from server.utils.files_elevation import apply_files_elevation_payload 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) data = payload.model_dump(exclude_none=True)
apply_files_elevation_payload(data) 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 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"): 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") raise HTTPException(status_code=404, detail="Server not found")
from server.utils.files_elevation import apply_files_elevation_payload 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 # If preset_id is provided (and not None), resolve the preset password
update_data = payload.model_dump(exclude_unset=True) update_data = payload.model_dump(exclude_unset=True)
apply_files_elevation_payload(update_data) 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"): 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_repo = PasswordPresetRepositoryImpl(db)
preset = await preset_repo.get_by_id(update_data["preset_id"]) 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: def _server_to_dict(server: Server) -> dict:
"""Convert Server model to API response dict (hide sensitive fields)""" """Convert Server model to API response dict (hide sensitive fields)"""
from server.utils.files_elevation import get_files_elevation from server.utils.files_elevation import get_files_elevation
from server.utils.rdp_config import get_rdp_config
rdp = get_rdp_config(server)
return { return {
"id": server.id, "id": server.id,
@@ -2087,10 +2033,6 @@ def _server_to_dict(server: Server) -> dict:
"protocols": server.protocols, "protocols": server.protocols,
"extra_attrs": server.extra_attrs, "extra_attrs": server.extra_attrs,
"files_elevation": get_files_elevation(server).value, "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, "connectivity": server.connectivity,
"ssh_key_configured": server.ssh_key_configured, "ssh_key_configured": server.ssh_key_configured,
"is_online": server.is_online, "is_online": server.is_online,
+64 -35
View File
@@ -1321,6 +1321,28 @@ class IpAllowlistSaveRequest(BaseModel):
enabled: Optional[bool] = None # None = don't change 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) @router.post("/ip-allowlist", response_model=dict)
async def set_ip_allowlist( async def set_ip_allowlist(
payload: IpAllowlistSaveRequest, payload: IpAllowlistSaveRequest,
@@ -1330,37 +1352,44 @@ async def set_ip_allowlist(
): ):
"""Save manual IPs, subscription URL, and/or enabled toggle.""" """Save manual IPs, subscription URL, and/or enabled toggle."""
from server.config import settings as _settings 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: if payload.manual_ips:
_validate_ip_list(payload.manual_ips) _validate_ip_list(payload.manual_ips)
repo = SettingRepositoryImpl(db) 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_cleaned = [ip.strip() for ip in payload.manual_ips if ip.strip()]
manual_val = ",".join(manual_cleaned) manual_val = ",".join(manual_cleaned)
await repo.set("login_manual_ips", manual_val) 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: if payload.subscription_url is not None:
sub_url = payload.subscription_url.strip() sub_url = payload.subscription_url.strip()
await repo.set("login_subscription_url", sub_url) await repo.set("login_subscription_url", sub_url)
_settings.LOGIN_SUBSCRIPTION_URL = sub_url changes["login_subscription_url"] = sub_url
sub_url_changed = bool(sub_url)
# If no subscription URL, rebuild combined from manual only if payload.subscription_url is not None:
if not _settings.LOGIN_SUBSCRIPTION_URL: 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_allowed_ips", manual_val)
await repo.set("login_subscription_ips", "") await repo.set("login_subscription_ips", "")
_settings.LOGIN_ALLOWED_IPS = manual_val changes["login_allowed_ips"] = manual_val
_settings.LOGIN_SUBSCRIPTION_IPS = "" changes["login_subscription_ips"] = ""
await publish_setting_changes(changes)
if sub_url_now:
refresh = await do_refresh()
audit_repo = AuditLogRepositoryImpl(db) audit_repo = AuditLogRepositoryImpl(db)
from server.domain.models import AuditLog from server.domain.models import AuditLog
@@ -1373,13 +1402,11 @@ async def set_ip_allowlist(
ip_address=ip_address, ip_address=ip_address,
)) ))
# Trigger refresh if subscription URL is present return _allowlist_save_response(
if sub_url_changed or _settings.LOGIN_SUBSCRIPTION_URL: manual_count=len(manual_cleaned),
from server.background.ip_allowlist_refresh import _do_refresh refresh=refresh,
import asyncio has_subscription=bool(sub_url_now),
asyncio.create_task(_do_refresh()) )
return {"success": True, "manual_count": len(manual_cleaned)}
@router.post("/ip-allowlist/toggle", response_model=dict) @router.post("/ip-allowlist/toggle", response_model=dict)
@@ -1390,12 +1417,12 @@ async def toggle_ip_allowlist(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Quick toggle for allowlist enforcement (no IP list changes).""" """Quick toggle for allowlist enforcement (no IP list changes)."""
from server.config import settings as _settings
val = "true" if enabled else "false" val = "true" if enabled else "false"
repo = SettingRepositoryImpl(db) repo = SettingRepositoryImpl(db)
await repo.set("login_allowlist_enabled", val) 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) audit_repo = AuditLogRepositoryImpl(db)
from server.domain.models import AuditLog from server.domain.models import AuditLog
@@ -1426,7 +1453,11 @@ async def add_manual_ips(
val = ",".join(merged) val = ",".join(merged)
repo = SettingRepositoryImpl(db) repo = SettingRepositoryImpl(db)
await repo.set("login_manual_ips", val) 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 # S-07: Audit log
audit_repo = AuditLogRepositoryImpl(db) audit_repo = AuditLogRepositoryImpl(db)
@@ -1438,9 +1469,6 @@ async def add_manual_ips(
ip_address=request.client.host if request.client else "", 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} return {"success": True, "manual_ips": merged}
@@ -1459,7 +1487,11 @@ async def remove_allowlist_ip(
val = ",".join(remaining) val = ",".join(remaining)
repo = SettingRepositoryImpl(db) repo = SettingRepositoryImpl(db)
await repo.set("login_manual_ips", val) 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 # S-07: Audit log
audit_repo = AuditLogRepositoryImpl(db) audit_repo = AuditLogRepositoryImpl(db)
@@ -1471,9 +1503,6 @@ async def remove_allowlist_ip(
ip_address=request.client.host if request.client else "", 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} return {"success": True, "removed": ip, "remaining": remaining}
+43 -20
View File
@@ -626,6 +626,7 @@ async def diagnose_push(
""" """
import shlex import shlex
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command 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.infrastructure.database.server_repo import ServerRepositoryImpl
from server.utils.sync_error_message import translate_sync_error_message from server.utils.sync_error_message import translate_sync_error_message
@@ -643,6 +644,7 @@ async def diagnose_push(
"path_exists": None, "path_exists": None,
"path_perms": None, "path_perms": None,
"path_writable": None, "path_writable": None,
"path_elevated": False,
"errors": [], "errors": [],
} }
@@ -699,16 +701,15 @@ async def diagnose_push(
# 4. Write test # 4. Write test
if result["path_exists"]: if result["path_exists"]:
try: try:
test_file = remote_join(dest, ".nexus_diag_test_$$") test_file = remote_join(dest, ".nexus_diag_test")
r = await exec_ssh_command( write_cmd = f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}"
server, r = await exec_ssh_command_with_fallback(server, write_cmd, timeout=10)
f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}",
timeout=10,
)
result["path_writable"] = r["exit_code"] == 0 result["path_writable"] = r["exit_code"] == 0
if r["exit_code"] != 0: if r["exit_code"] != 0:
stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or "" stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or ""
result["errors"].append(f"目标路径不可写: {stderr_zh}") result["errors"].append(f"目标路径不可写: {stderr_zh}")
elif r.get("elevated"):
result["path_elevated"] = True
except Exception as e: except Exception as e:
result["errors"].append(f"写入测试失败: {e}") result["errors"].append(f"写入测试失败: {e}")
result["path_writable"] = False result["path_writable"] = False
@@ -864,7 +865,7 @@ async def file_diff(
# ── File Upload via SFTP ── # ── 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: def _collect_multipart_upload_files(form) -> list:
@@ -927,11 +928,22 @@ async def _sftp_upload_one(
except Exception as e: except Exception as e:
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from 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( await _audit_sync(
"file_upload", "file_upload",
"server", "server",
server_id, server_id,
f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}", audit_detail,
admin_username, admin_username,
request, request,
) )
@@ -940,6 +952,7 @@ async def _sftp_upload_one(
"remote_path": full_remote_path, "remote_path": full_remote_path,
"filename": safe_filename, "filename": safe_filename,
"size": len(content), "size": len(content),
"permission_fixup": permission_fixup,
} }
@@ -1009,6 +1022,7 @@ async def upload_file(
"remote_path": one["remote_path"], "remote_path": one["remote_path"],
"filename": one["filename"], "filename": one["filename"],
"size": one["size"], "size": one["size"],
"permission_fixup": one.get("permission_fixup"),
} }
return { return {
@@ -1453,8 +1467,8 @@ async def download_file(
await ssh_pool.release(server.id) await ssh_pool.release(server.id)
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录") raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
# H-15: Download file size limit (100MB) # H-15: Download file size limit (500MB)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 MAX_DOWNLOAD_SIZE = 524_288_000
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE: if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
await ssh_pool.release(server.id) await ssh_pool.release(server.id)
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB") 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 " chmod_prefix = "chmod -R " if recursive else "chmod "
chown_prefix = "chown -R " if recursive else "chown " 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 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) chmod_cmd = f"{chmod_prefix}{payload.mode} {path_q}"
if result["exit_code"] != 0: chmod_result = await exec_ssh_command_with_fallback(server, chmod_cmd, timeout=timeout)
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:300] if chmod_result["exit_code"] != 0:
err = ((chmod_result.get("stderr") or "") + (chmod_result.get("stdout") or ""))[:300]
raise HTTPException( raise HTTPException(
status_code=403 if "permission denied" in err.lower() else 502, status_code=403 if "permission denied" in err.lower() else 502,
detail=err or "chmod 失败", 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}"] audit_bits = [f"chmod {'-R ' if recursive else ''}{payload.mode}"]
if payload.owner: if payload.owner:
audit_bits.append( audit_bits.append(
@@ -1767,7 +1790,7 @@ async def chmod_file(
"owner": payload.owner, "owner": payload.owner,
"group": payload.group, "group": payload.group,
"recursive": recursive, "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: 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] lines = [path]
for item in sorted(items, key=lambda x: x.get("name") or ""): 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() digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
return f'W/"{digest}"' 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=+,:-]+$") _RSYNC_CHMOD_RE = re.compile(r"^[DF0-9a-zA-Z=+,:-]+$")
def rsync_push_permission_summary() -> str | None: def rsync_push_permission_summary(server: Server | None = None) -> str | None:
"""Human/log snapshot of rsync --chown/--chmod applied on real pushes.""" """Human/log snapshot of rsync --chown/--chmod and elevation applied on real pushes."""
from server.config import settings from server.config import settings
from server.utils.rsync_elevation import rsync_elevation_log_hint
parts: list[str] = [] parts: list[str] = []
chown = (settings.RSYNC_PUSH_CHOWN or "").strip() chown = (settings.RSYNC_PUSH_CHOWN or "").strip()
@@ -54,6 +55,10 @@ def rsync_push_permission_summary() -> str | None:
parts.append(f"chown={chown}") parts.append(f"chown={chown}")
if chmod and _RSYNC_CHMOD_RE.fullmatch(chmod): if chmod and _RSYNC_CHMOD_RE.fullmatch(chmod):
parts.append(f"chmod={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 return " ".join(parts) if parts else None
@@ -185,7 +190,7 @@ class SyncEngineV2:
operator=operator, operator=operator,
status="running", status="running",
sync_mode=sync_mode, sync_mode=sync_mode,
push_permission=rsync_push_permission_summary(), push_permission=rsync_push_permission_summary(server),
started_at=datetime.now(timezone.utc), started_at=datetime.now(timezone.utc),
) )
async with _db_lock: async with _db_lock:
@@ -452,9 +457,36 @@ async def _rsync_push(
) -> dict: ) -> dict:
"""Execute rsync on Nexus host, pushing to target server via SSH. """Execute rsync on Nexus host, pushing to target server via SSH.
Handles both password auth (sshpass) and key auth (temp key file). Non-root servers with ``files_elevation`` not ``off`` use
Returns {exit_code, stdout, stderr}. ``--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 import shlex
ssh_user = server.username or "root" ssh_user = server.username or "root"
@@ -463,7 +495,6 @@ async def _rsync_push(
remote_dest = normalize_remote_abs_path(to_posix(target_path)) remote_dest = normalize_remote_abs_path(to_posix(target_path))
rsync_remote = f"{ssh_user}@{ssh_host}:{remote_dest}" rsync_remote = f"{ssh_user}@{ssh_host}:{remote_dest}"
# Build rsync args
args = ["rsync", "-az"] args = ["rsync", "-az"]
if sync_mode == "full": if sync_mode == "full":
args.append("--delete") args.append("--delete")
@@ -481,18 +512,17 @@ async def _rsync_push(
if not dry_run: if not dry_run:
args.extend(rsync_push_permission_args()) 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}" ssh_opts = f"ssh -o StrictHostKeyChecking=no -p {ssh_port}"
# Auth: prepare temp key file or sshpass
key_file = None key_file = None
env_override = None env_override = None
try: try:
if server.auth_method == "password" and server.password: if server.auth_method == "password" and server.password:
password = decrypt_value(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} env_override = {"SSHPASS": password}
args = ["sshpass", "-e"] + args args = ["sshpass", "-e"] + args
ssh_opts += " -o PubkeyAuthentication=no" ssh_opts += " -o PubkeyAuthentication=no"
@@ -512,11 +542,9 @@ async def _rsync_push(
return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"} return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"}
args.extend(["-e", ssh_opts]) args.extend(["-e", ssh_opts])
# source_path: Nexus 主机本地目录(生产环境为 Linux);保持原样供 rsync 读取
args.append(source_path.rstrip("/") + "/") args.append(source_path.rstrip("/") + "/")
args.append(rsync_remote.rstrip("/") + "/") args.append(rsync_remote.rstrip("/") + "/")
# Execute rsync on Nexus host
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*args, *args,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
+37 -8
View File
@@ -8,8 +8,11 @@ Data model:
login_allowlist_enabled "true" / "false" master switch login_allowlist_enabled "true" / "false" master switch
""" """
from __future__ import annotations
import asyncio import asyncio
import logging import logging
from dataclasses import dataclass
logger = logging.getLogger("nexus.ip_allowlist_refresh") logger = logging.getLogger("nexus.ip_allowlist_refresh")
@@ -18,18 +21,32 @@ _last_refresh_at: str = ""
_last_subscription_count: int = 0 _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(): async def ip_allowlist_refresh_loop():
"""Every 2 hours, re-fetch subscription IPs and rebuild combined allowlist.""" """Every 2 hours, re-fetch subscription IPs and rebuild combined allowlist."""
logger.info("IP allowlist refresh loop started (interval=%sh)", REFRESH_INTERVAL // 3600) logger.info("IP allowlist refresh loop started (interval=%sh)", REFRESH_INTERVAL // 3600)
# Initial refresh shortly after startup # Initial refresh shortly after startup
await asyncio.sleep(5) await asyncio.sleep(5)
await _do_refresh() await do_refresh()
while True: while True:
await asyncio.sleep(REFRESH_INTERVAL) 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.""" """Fetch subscription, replace subscription IPs, rebuild combined list."""
global _last_refresh_at, _last_subscription_count global _last_refresh_at, _last_subscription_count
from server.config import settings from server.config import settings
@@ -37,7 +54,7 @@ async def _do_refresh():
sub_url = (settings.LOGIN_SUBSCRIPTION_URL or "").strip() sub_url = (settings.LOGIN_SUBSCRIPTION_URL or "").strip()
if not sub_url: if not sub_url:
return return RefreshResult(ok=True)
logger.info("Refreshing subscription IPs from: %s", sub_url[:80]) logger.info("Refreshing subscription IPs from: %s", sub_url[:80])
try: try:
@@ -46,11 +63,11 @@ async def _do_refresh():
resp = await client.get(sub_url, follow_redirects=True) resp = await client.get(sub_url, follow_redirects=True)
if resp.status_code != 200: if resp.status_code != 200:
logger.warning("Subscription returned HTTP %s", resp.status_code) 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) new_sub_ips = parse_subscription_content(resp.text)
except Exception as e: except Exception as e:
logger.error("Subscription fetch failed: %s", e) logger.error("Subscription fetch failed: %s", e)
return return RefreshResult(ok=False, error=f"拉取订阅失败: {e}")
# Manual IPs (always preserved) # Manual IPs (always preserved)
manual_raw = (settings.LOGIN_MANUAL_IPS or "").strip() manual_raw = (settings.LOGIN_MANUAL_IPS or "").strip()
@@ -70,16 +87,21 @@ async def _do_refresh():
try: try:
from server.infrastructure.database.session import AsyncSessionLocal from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.setting_repo import SettingRepositoryImpl from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.settings_broadcast import publish_setting_changes
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
repo = SettingRepositoryImpl(session) repo = SettingRepositoryImpl(session)
# Subscription IPs — replaced entirely
await repo.set("login_subscription_ips", sub_val) await repo.set("login_subscription_ips", sub_val)
# Combined — for auth check
await repo.set("login_allowed_ips", combined_val) await repo.set("login_allowed_ips", combined_val)
settings.LOGIN_SUBSCRIPTION_IPS = sub_val settings.LOGIN_SUBSCRIPTION_IPS = sub_val
settings.LOGIN_ALLOWED_IPS = combined_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 from server.utils.display_time import format_beijing
_last_refresh_at = format_beijing(with_label=False) _last_refresh_at = format_beijing(with_label=False)
@@ -88,8 +110,15 @@ async def _do_refresh():
"Subscription IPs refreshed: %d → combined %d (+ %d manual)", "Subscription IPs refreshed: %d → combined %d (+ %d manual)",
len(new_sub_ips), len(combined), len(manual_ips), 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: except Exception as e:
logger.error("Failed to save subscription IPs: %s", e) logger.error("Failed to save subscription IPs: %s", e)
return RefreshResult(ok=False, error=f"保存订阅 IP 失败: {e}")
def get_last_refresh_time() -> str: def get_last_refresh_time() -> str:
-4
View File
@@ -57,10 +57,6 @@ class Settings(BaseSettings):
# SSH — 运维平台连接大量未知子机,默认跳过主机密钥校验(安装向导可在 .env 写 true 覆盖) # SSH — 运维平台连接大量未知子机,默认跳过主机密钥校验(安装向导可在 .env 写 true 覆盖)
SSH_STRICT_HOST_CHECKING: str = "false" 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 (mutable via .env / settings table — not immutable secrets)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30 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失效)") 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): class LoginAttempt(Base):
"""Login attempt log for brute-force protection""" """Login attempt log for brute-force protection"""
__tablename__ = "login_attempts" __tablename__ = "login_attempts"
@@ -530,22 +540,3 @@ class QuickCommand(Base):
created_by = Column(String(100), nullable=True, comment="创建人") created_by = Column(String(100), nullable=True, comment="创建人")
created_at = Column(DateTime, default=_utcnow) created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_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`), UNIQUE KEY `uq_admin_refresh_token_hash` (`token_hash`),
INDEX `idx_admin_refresh_admin_expires` (`admin_id`, `expires_at`) INDEX `idx_admin_refresh_admin_expires` (`admin_id`, `expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS `rdp_hosts` ( "DROP TABLE IF EXISTS `rdp_hosts`",
`id` INT AUTO_INCREMENT PRIMARY KEY, """CREATE TABLE IF NOT EXISTS `admin_ui_preferences` (
`name` VARCHAR(100) NOT NULL COMMENT '显示名称', `admin_id` INT NOT NULL COMMENT '管理员 ID',
`host` VARCHAR(255) NOT NULL COMMENT 'IP 或域名', `context` VARCHAR(50) NOT NULL COMMENT 'UI 上下文',
`port` INT NOT NULL DEFAULT 3389 COMMENT 'RDP 端口', `payload` JSON NOT NULL COMMENT 'JSON 状态',
`username` VARCHAR(100) NOT NULL COMMENT 'RDP 用户名',
`password` TEXT NOT NULL COMMENT 'RDP 密码(Fernet 加密)',
`description` TEXT NULL COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `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""", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
] ]
async with AsyncSessionLocal() as session: 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 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: async def start_settings_subscriber() -> None:
"""Subscribe to nexus:settings — called during app startup (every worker).""" """Subscribe to nexus:settings — called during app startup (every worker)."""
global _subscriber_task, _redis_subscriber global _subscriber_task, _redis_subscriber
@@ -33,17 +33,17 @@ async def probe_files_capability(server: Server) -> dict:
whitelist_probe = await exec_ssh_command( whitelist_probe = await exec_ssh_command(
server, 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, timeout=15,
) )
whitelist_ok = can_sudo_nopasswd and whitelist_probe.get("exit_code") == 0 whitelist_ok = can_sudo_nopasswd and whitelist_probe.get("exit_code") == 0
if can_sudo_nopasswd and whitelist_ok: if can_sudo_nopasswd and whitelist_ok:
message = "已配置免密 sudo,文件管理可自动提权" message = "已配置免密 sudo,文件管理与推送可自动提权"
elif can_sudo_nopasswd: elif can_sudo_nopasswd:
message = ( message = (
"可免密 sudo,但 chmod/chown 可能未列入白名单;" "可免密 sudo,但 chmod/chown/rsync 可能未列入白名单;"
"请参考仓库内 sudoers 示例补全" "文件推送至 www 目录会失败,请参考仓库内 sudoers 示例补全"
) )
else: else:
message = ( 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.health import router as health_router
from server.api.assets import router as assets_router from server.api.assets import router as assets_router
from server.api.webssh import router as webssh_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.sync_v2 import router as sync_v2_router
from server.api.files import router as files_router from server.api.files import router as files_router
from server.api.search import router as search_router from server.api.search import router as search_router
@@ -677,10 +675,6 @@ app.include_router(assets_router)
# Web SSH Terminal # Web SSH Terminal
app.include_router(webssh_router) 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) # Sync Engine v2 (Step 4)
app.include_router(sync_v2_router) app.include_router(sync_v2_router)
@@ -693,6 +687,11 @@ app.include_router(search_router)
# Terminal Quick Commands # Terminal Quick Commands
app.include_router(terminal_router) 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 ── # ── Custom 404: return blank HTML to hide backend identity ──
@app.exception_handler(StarletteHTTPException) @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``.""" """Clone Pydantic error dicts with translated ``msg`` and ``loc_zh``."""
translated: list[dict[str, Any]] = [] translated: list[dict[str, Any]] = []
for err in errors: 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( item["msg"] = translate_validation_msg(
str(err.get("msg", "")), str(err.get("msg", "")),
str(err.get("type", "")), 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")) item["loc_zh"] = translate_loc_zh(err.get("loc"))
translated.append(item) 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 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(): def test_browse_server_cache_304_path():
payload = {"path": "/tmp", "items": [{"name": "x", "modified": "t", "size": 1}]} payload = {"path": "/tmp", "items": [{"name": "x", "modified": "t", "size": 1}]}
etag = compute_browse_etag("/tmp", payload["items"]) 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