Compare commits
20 Commits
aeca2fd7cb
...
f11ad35708
| Author | SHA1 | Date | |
|---|---|---|---|
| f11ad35708 | |||
| 84b388fae2 | |||
| 0a5111a959 | |||
| 31846c4212 | |||
| 2e8daad802 | |||
| a94d340ec0 | |||
| 8356425814 | |||
| 2a6f526bc9 | |||
| 996403ad86 | |||
| 4e117c04ea | |||
| 79105dcec8 | |||
| d3c0e57a08 | |||
| af912662de | |||
| c69e45a571 | |||
| 130cc840c7 | |||
| 704764bd7e | |||
| 426bafd169 | |||
| dc4593f8b5 | |||
| 341b130d9b | |||
| af47b24450 |
@@ -12,7 +12,8 @@ upstream nexus_api {
|
||||
# WebSocket log without query string (JWT must not hit access_log)
|
||||
log_format nexus_ws '$remote_addr - [$time_local] "$request_method $uri" $status $body_bytes_sent';
|
||||
|
||||
client_max_body_size 100m;
|
||||
client_max_body_size 500m;
|
||||
client_body_timeout 600s;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://nexus_api;
|
||||
@@ -21,8 +22,8 @@ location /api/ {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
|
||||
@@ -1567,3 +1567,93 @@
|
||||
{"ts":"2026-06-10T20:57:52+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-10T20:57:52+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-10T20:57:52+08:00","date":"2026-06-10","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md 32lines"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-10T20:58:56+08:00","date":"2026-06-10","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md 32lines"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-10T20:59:12+08:00","date":"2026-06-10","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md 36lines"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-10T21:18:32+08:00","date":"2026-06-10","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-guacd-handshake-fix.md 30lines"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-10T21:28:00+08:00","date":"2026-06-10","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md 48lines"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-hosts-standalone.md"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-10T23:21:27+08:00","date":"2026-06-10","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md 48lines"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-10T23:23:19+08:00","date":"2026-06-10","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"changelog","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md 48lines"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"audit","result":"PASS","detail":"2026-06-10-rdp-feature-removal.md"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-10T23:23:34+08:00","date":"2026-06-10","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-servers-search-history.md 44lines"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"audit","result":"BLOCK","detail":"file not found"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"review","result":"BLOCK","detail":"no audit file"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T17:47:02+08:00","date":"2026-06-11","gate":"summary","result":"BLOCK","detail":"4/7"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"changelog","result":"PASS","detail":"2026-06-11-servers-search-history.md 44lines"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"audit","result":"PASS","detail":"2026-06-11-servers-search-history.md"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-11T17:47:40+08:00","date":"2026-06-11","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ server {
|
||||
}
|
||||
|
||||
# Upload limit
|
||||
client_max_body_size 100m;
|
||||
client_max_body_size 500m;
|
||||
|
||||
# Well-known for SSL cert validation
|
||||
location ~ \\.well-known {
|
||||
|
||||
@@ -75,5 +75,5 @@ server {
|
||||
error_log {{LOG_DIR}}/{{SERVER_NAME}}_nexus.error.log;
|
||||
|
||||
# ── Upload limit ──
|
||||
client_max_body_size 100m;
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
|
||||
@@ -109,5 +109,5 @@ server {
|
||||
error_log {{LOG_DIR}}/{{SERVER_NAME}}.error.log;
|
||||
|
||||
# ── Upload limit ──
|
||||
client_max_body_size 100m;
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
|
||||
@@ -40,10 +40,6 @@ services:
|
||||
ports:
|
||||
- "${REDIS_PUBLISH_PORT:-6379}:6379"
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.5.5
|
||||
restart: unless-stopped
|
||||
|
||||
nexus:
|
||||
build:
|
||||
context: .
|
||||
@@ -54,8 +50,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
guacd:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${NEXUS_PUBLISH_PORT:-8600}:8600"
|
||||
environment:
|
||||
@@ -75,8 +69,6 @@ services:
|
||||
NEXUS_API_KEY: ${NEXUS_API_KEY:-}
|
||||
NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:-}
|
||||
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:-http://localhost:8600}
|
||||
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
|
||||
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
|
||||
env_file:
|
||||
- path: docker/.env
|
||||
required: false
|
||||
|
||||
@@ -11,15 +11,7 @@
|
||||
name: nexus-prod
|
||||
|
||||
services:
|
||||
guacd:
|
||||
image: guacamole/guacd:1.5.5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nexus-internal
|
||||
|
||||
nexus:
|
||||
depends_on:
|
||||
- guacd
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.prod
|
||||
@@ -46,8 +38,6 @@ services:
|
||||
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:?set NEXUS_API_BASE_URL}
|
||||
NEXUS_DB_POOL_SIZE: ${NEXUS_DB_POOL_SIZE:-30}
|
||||
NEXUS_DB_MAX_OVERFLOW: ${NEXUS_DB_MAX_OVERFLOW:-20}
|
||||
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
|
||||
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
|
||||
volumes:
|
||||
- nexus-state:/var/lib/nexus
|
||||
- nexus-web-data:/app/web/data
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 审计 — 移除浏览器 RDP 功能
|
||||
|
||||
**Changelog**: `docs/changelog/2026-06-10-rdp-feature-removal.md`
|
||||
|
||||
## 变更文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `server/api/rdp.py` | 删除 WS 隧道 |
|
||||
| `server/api/rdp_hosts.py` | 删除 CRUD |
|
||||
| `server/api/servers.py` | 删除 `/rdp-connect` 与 rdp 字段 |
|
||||
| `server/api/schemas.py` | 删除 rdp_* 请求字段 |
|
||||
| `server/main.py` | 注销 rdp 路由 |
|
||||
| `server/config.py` | 删除 GUACD_* |
|
||||
| `server/domain/models/__init__.py` | 删除 RdpHost |
|
||||
| `server/infrastructure/database/migrations.py` | DROP rdp_hosts |
|
||||
| `server/infrastructure/database/rdp_host_repo.py` | 删除 |
|
||||
| `server/infrastructure/guacamole/__init__.py` | 删除 |
|
||||
| `server/infrastructure/guacamole/protocol.py` | 删除 |
|
||||
| `server/infrastructure/guacamole/tunnel.py` | 删除 |
|
||||
| `server/infrastructure/guacamole/ws_tunnel.py` | 删除 |
|
||||
| `server/utils/rdp_config.py` | 删除 |
|
||||
| `docker-compose.yml` | 删除 guacd 服务 |
|
||||
| `docker/docker-compose.prod.yml` | 删除 guacd 侧车 |
|
||||
| `frontend/src/App.vue` | 移除侧栏 3389 |
|
||||
| `frontend/src/router/index.ts` | 移除 /rdp 路由 |
|
||||
| `frontend/src/composables/useRoutePrefetch.ts` | 移除预载 |
|
||||
| `frontend/src/pages/RdpPage.vue` | 删除 |
|
||||
| `frontend/src/components/rdp/RdpHostFormDialog.vue` | 删除 |
|
||||
| `frontend/src/composables/rdp/useRdpSession.ts` | 删除 |
|
||||
| `frontend/src/composables/rdp/useRdpHostList.ts` | 删除 |
|
||||
| `frontend/src/types/guacamole.d.ts` | 删除 |
|
||||
| `frontend/src/types/api.ts` | 删除 rdp 响应字段 |
|
||||
| `frontend/src/api/index.ts` | 注释更新 |
|
||||
| `frontend/package.json` | 移除 guacamole-common-js |
|
||||
| `frontend/package-lock.json` | 锁文件同步 |
|
||||
| `frontend/e2e/helpers.mjs` | 移除侧栏项 |
|
||||
| `tests/test_rdp_hosts.py` | 删除 |
|
||||
| `tests/test_rdp_config.py` | 删除 |
|
||||
| `tests/test_guacamole_protocol.py` | 删除 |
|
||||
|
||||
## Step 3(规则扫描)
|
||||
|
||||
| 规则 | 结论 | 说明 |
|
||||
|------|------|------|
|
||||
| 攻击面 | PASS | 移除 WS 隧道与 guacd 依赖,减少暴露 |
|
||||
| 密钥 | PASS | rdp_hosts 表 DROP;无新增凭据路径 |
|
||||
| 鉴权 | N/A | 删除端点,无新 API |
|
||||
| 静默吞错 | PASS | 无新增异常处理 |
|
||||
| 依赖 | PASS | 移除 guacamole-common-js |
|
||||
|
||||
## Closure(走读)
|
||||
|
||||
| 文件 | 结论 |
|
||||
|------|------|
|
||||
| `main.py` | SAFE — 无残留 rdp import |
|
||||
| `servers.py` | SAFE — 无 rdp_config 引用 |
|
||||
| `migrations.py` | SAFE — DROP IF EXISTS 幂等 |
|
||||
| `App.vue` | SAFE — 菜单项已移除 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] changelog + 本审计
|
||||
- [x] `local_verify` + frontend type-check
|
||||
- [ ] 生产部署与健康检查
|
||||
- [ ] 侧栏无「3389远程」
|
||||
@@ -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 | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H4 | 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` | 仅加载历史、不恢复 last;record 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。
|
||||
@@ -22,4 +22,5 @@ deploy ALL=(ALL) NOPASSWD: \
|
||||
/bin/tar, /usr/bin/tar, \
|
||||
/usr/bin/unzip, /usr/bin/zip, \
|
||||
/usr/bin/tee, /bin/cat, /usr/bin/cat, \
|
||||
/usr/bin/touch, /bin/touch
|
||||
/usr/bin/touch, /bin/touch, \
|
||||
/usr/bin/rsync, /bin/rsync
|
||||
|
||||
@@ -223,7 +223,7 @@ docker compose -f docker/docker-compose.prod.yml exec mysql \
|
||||
| `/ws/` | WebSocket,`Upgrade` + `Connection upgrade`,超时 3600s |
|
||||
| `/health` | 反代(或仅内网探测) |
|
||||
| `/app/` | 反代(容器内 StaticFiles) |
|
||||
| `client_max_body_size` | `100m`(上传/推送) |
|
||||
| `client_max_body_size` | `500m`(上传/推送) |
|
||||
|
||||
**WS 日志**:勿记录 query 中的 JWT(示例配置已用 `$uri` 无 `$args`)。
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 实施说明 — rsync 推送 sudo 提权
|
||||
|
||||
## 背景
|
||||
|
||||
非 root SSH 用户推送至宝塔 `www:www` 站点目录时,rsync 接收端无写权限;文件管理的 `files_elevation` 此前未作用于 rsync 推送。
|
||||
|
||||
## 方案
|
||||
|
||||
与文件管理一致:**root 不提权**;**非 root** 且 `files_elevation` 非 `off`(默认 `auto_sudo`)→ rsync 附加 `--rsync-path="sudo -n rsync"`。仅显式 `off` 禁用。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `server/utils/rsync_elevation.py` | 新增 |
|
||||
| `server/application/services/sync_engine_v2.py` | `_rsync_push` 提权与重试 |
|
||||
| `server/api/sync_v2.py` | diagnose 写入测试 sudo 回退 |
|
||||
| `server/api/servers.py` | setup-files-sudo 模板加 rsync |
|
||||
| `docs/deploy/nexus-files-sudoers.example` | 加 rsync |
|
||||
| `server/infrastructure/ssh/files_capability.py` | 白名单探测含 rsync |
|
||||
| `tests/test_rsync_elevation.py` | 单测 |
|
||||
|
||||
## 目标机要求
|
||||
|
||||
`/etc/sudoers.d/nexus-files` 须含 `NOPASSWD: /usr/bin/rsync`(或 `/bin/rsync`)。可通过服务器页「配置 sudo」或手动安装示例文件。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
pytest tests/test_rsync_elevation.py tests/test_rsync_push_permissions.py -q
|
||||
```
|
||||
|
||||
## 回滚
|
||||
|
||||
Revert 上述文件;推送恢复为仅 root SSH 可写站点目录。
|
||||
@@ -0,0 +1,34 @@
|
||||
# 内置 Web 浏览器 — 设计说明
|
||||
|
||||
## 背景与目标
|
||||
|
||||
运维需在 Nexus 内快速预览子机站点(域名),不必切换外部浏览器;与终端、文件管理并列的运维入口。
|
||||
|
||||
## 方案对比
|
||||
|
||||
| 方案 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| **iframe 嵌入** | 无后端、实现快、无 SSRF | 部分站点 `X-Frame-Options` 禁止嵌入 |
|
||||
| HTTP 反向代理 | 可绕过 frame 限制 | SSRF 风险、需维护代理与 Cookie |
|
||||
| 仅外链新标签 | 零风险 | 非「内置」 |
|
||||
|
||||
**选定**:iframe + 地址栏 + 新标签兜底;首版不做 HTTP 代理。
|
||||
|
||||
## 接口与路由
|
||||
|
||||
- 前端 `#/browser?url=https://example.com`
|
||||
- 服务器列表「站点」→ 带 `url`(由 `domain` 推导 `https://{host}`,不含 SSH 端口)
|
||||
|
||||
## 安全
|
||||
|
||||
- 仅允许 `http:` / `https:`;拒绝 `javascript:`、`data:`、凭据 URL
|
||||
- iframe `sandbox`:`allow-scripts allow-same-origin allow-forms allow-popups allow-downloads`
|
||||
- 无后端 fetch,不引入 SSRF
|
||||
- 不记录浏览审计(被动预览,非 CUD)
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 侧栏「浏览器」进入全屏 iframe 页
|
||||
- [ ] 地址栏输入 URL 可导航;刷新 / 新标签打开
|
||||
- [ ] 服务器列表「站点」跳转并加载域名
|
||||
- [ ] 非法 URL 拒绝并提示
|
||||
@@ -268,7 +268,7 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
client_max_body_size 100m;
|
||||
client_max_body_size 500m;
|
||||
|
||||
access_log /var/log/nginx/nexus_access.log;
|
||||
error_log /var/log/nginx/nexus_error.log;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 温胜夜推送提权 — 生产验证报告
|
||||
|
||||
**日期**: 2026-06-11
|
||||
**部署版本**: `84b388fa` fix(sync): rsync 推送非 root 自动 sudo 提权
|
||||
|
||||
## 背景
|
||||
|
||||
推送失败:`touch ... m.wenshengye.com ... Permission denied`(SSH 用户 `ubuntu`,非 root)。
|
||||
|
||||
## 处理
|
||||
|
||||
1. 部署 rsync `--rsync-path=sudo -n rsync`(非 root + 默认 `auto_sudo`)
|
||||
2. 批量 sudoers 探测:两台 `sudo -n rsync` 已为 `already_ok`
|
||||
3. 生产探针推送(`scripts/verify_push_elevation.py --name 温胜夜 --push`)
|
||||
|
||||
## 结果
|
||||
|
||||
| ID | 名称 | SSH | 目标路径 | rsync sudo | 写入(sudo) | dry-run | 实推 |
|
||||
|----|------|-----|----------|------------|------------|---------|------|
|
||||
| 313 | 武汉市温胜夜科技有限公司 | ubuntu | `/www/wwwroot/wenshengye.com` | ✓ | ✓ | exit 0 | exit 0, elevated |
|
||||
| 314 | 子-武汉市温胜夜科技有限公司 | ubuntu | `/www/wwwroot/m.wenshengye.com` | ✓ | ✓ | exit 0 | exit 0, elevated |
|
||||
|
||||
**结论**: 2/2 通过。通道已恢复。
|
||||
|
||||
## 用户操作
|
||||
|
||||
原批次源目录 `/tmp/nexus_upload_dbc7758062ff` 为临时上传目录,可能已被清理,请在推送页**重新上传 ZIP/文件**后再推一次。
|
||||
|
||||
## 复验命令(生产容器内)
|
||||
|
||||
```bash
|
||||
cd /opt/nexus
|
||||
sudo docker cp scripts/verify_push_elevation.py nexus-prod-nexus-1:/app/scripts/
|
||||
sudo docker exec -w /app nexus-prod-nexus-1 python scripts/verify_push_elevation.py --name 温胜夜 --push
|
||||
```
|
||||
@@ -23,8 +23,42 @@ const SIDEBAR_LINKS = {
|
||||
'/audit': '审计日志',
|
||||
}
|
||||
|
||||
const E2E_ACCESS_TOKEN = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
|
||||
|
||||
/** Mock refresh + profile so Pinia bootstraps with a pre-minted JWT (bypass login IP allowlist). */
|
||||
export async function loginWithAccessToken(page, accessToken = E2E_ACCESS_TOKEN) {
|
||||
if (!accessToken) {
|
||||
throw new Error('Set NEXUS_E2E_ACCESS_TOKEN for token-based E2E login')
|
||||
}
|
||||
await page.route('**/api/auth/refresh', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ access_token: accessToken }),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/auth/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 1,
|
||||
username: USER,
|
||||
totp_enabled: false,
|
||||
created_at: '2020-01-01T00:00:00Z',
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
|
||||
await expect(page.getByRole('button', { name: USER })).toBeVisible({ timeout: 15000 })
|
||||
}
|
||||
|
||||
/** Restore session from worker refresh cookie (avoids parallel UI logins / lockout). */
|
||||
export async function login(page) {
|
||||
if (E2E_ACCESS_TOKEN) {
|
||||
return loginWithAccessToken(page, E2E_ACCESS_TOKEN)
|
||||
}
|
||||
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
|
||||
await expect(page.getByRole('button', { name: 'admin' })).toBeVisible({ timeout: 15000 })
|
||||
|
||||
Generated
-6
@@ -14,7 +14,6 @@
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"echarts": "^6.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30",
|
||||
@@ -1344,11 +1343,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/guacamole-common-js": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz",
|
||||
"integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg=="
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"echarts": "^6.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30",
|
||||
|
||||
@@ -223,6 +223,8 @@
|
||||
{{ snackbar.text }}
|
||||
</v-snackbar>
|
||||
|
||||
<GlobalBrowserPanel v-if="auth.isLoggedIn" :show-launcher="auth.isLoggedIn" />
|
||||
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
@@ -247,6 +249,8 @@ import {
|
||||
} from '@/composables/useScriptSubmitToast'
|
||||
import { clearCachedQueries } from '@/composables/useCachedQuery'
|
||||
import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
import { CACHED_PAGE_NAMES } from '@/constants/cachedPages'
|
||||
import { api } from '@/api'
|
||||
|
||||
@@ -326,8 +330,8 @@ const { mobile } = useDisplay()
|
||||
provide('nexusDrawer', drawer)
|
||||
|
||||
const mainLayoutClass = computed(() => ({
|
||||
'nexus-terminal-main': route.path === '/terminal' || route.path === '/rdp',
|
||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/rdp' && route.path !== '/login',
|
||||
'nexus-terminal-main': route.path === '/terminal',
|
||||
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
|
||||
}))
|
||||
|
||||
const scriptExecBarOffset = computed(() => {
|
||||
@@ -343,7 +347,7 @@ const opsItems = [
|
||||
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
|
||||
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
|
||||
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
|
||||
{ to: '/rdp', title: '3389远程', icon: 'mdi-remote-desktop' },
|
||||
{ to: '/browser', title: '浏览器', icon: 'mdi-web' },
|
||||
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
|
||||
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
|
||||
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
|
||||
|
||||
@@ -190,7 +190,7 @@ function filenameFromContentDisposition(header: string | null): string | null {
|
||||
return plain?.[1] ?? null
|
||||
}
|
||||
|
||||
/** GET binary download (e.g. /servers/{id}/rdp-file). */
|
||||
/** GET binary download (e.g. exported files). */
|
||||
export async function downloadGet(path: string): Promise<{ blob: Blob; filename: string }> {
|
||||
const res = await fetchAuthed(path, { method: 'GET' })
|
||||
if (res.status === 202) {
|
||||
|
||||
@@ -239,6 +239,10 @@ const owner = ref('')
|
||||
|
||||
const group = ref('')
|
||||
|
||||
const initialMode = ref('')
|
||||
const initialOwner = ref('')
|
||||
const initialGroup = ref('')
|
||||
|
||||
const capability = ref<FilesCapabilityResult | null>(null)
|
||||
|
||||
const capabilityLoading = ref(false)
|
||||
@@ -288,11 +292,14 @@ watch(
|
||||
|
||||
if (!open || !file) return
|
||||
|
||||
const fileOwner = file.owner || ''
|
||||
const fileGroup = file.group || ''
|
||||
mode.value = file.mode_octal || ''
|
||||
|
||||
owner.value = file.owner || ''
|
||||
|
||||
group.value = file.group && file.group !== file.owner ? file.group : ''
|
||||
owner.value = fileOwner
|
||||
group.value = fileGroup && fileGroup !== fileOwner ? fileGroup : ''
|
||||
initialMode.value = mode.value
|
||||
initialOwner.value = fileOwner
|
||||
initialGroup.value = fileGroup
|
||||
recursive.value = false
|
||||
|
||||
capability.value = null
|
||||
@@ -427,6 +434,10 @@ function submit() {
|
||||
const o = owner.value.trim()
|
||||
const g = group.value.trim()
|
||||
const rec = isDirectory.value && recursive.value
|
||||
const ownerChanged = o !== initialOwner.value.trim()
|
||||
const groupChanged = g !== (initialGroup.value.trim() && initialGroup.value.trim() !== initialOwner.value.trim()
|
||||
? initialGroup.value.trim()
|
||||
: '')
|
||||
if (rec) {
|
||||
const name = props.file?.name ?? '该目录'
|
||||
const ok = window.confirm(
|
||||
@@ -436,8 +447,8 @@ function submit() {
|
||||
}
|
||||
emit('apply', {
|
||||
mode: m,
|
||||
owner: o || undefined,
|
||||
group: g || undefined,
|
||||
owner: ownerChanged || groupChanged ? (o || undefined) : undefined,
|
||||
group: groupChanged ? (g || undefined) : undefined,
|
||||
recursive: rec || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="panelOpen && tabs.length"
|
||||
class="global-browser-float"
|
||||
:class="{ 'global-browser-float--maximized': maximized }"
|
||||
:style="maximized ? undefined : panelStyle"
|
||||
>
|
||||
<v-card border rounded="lg" class="global-browser-panel d-flex flex-column h-100">
|
||||
<div
|
||||
class="global-browser-toolbar d-flex align-center flex-shrink-0"
|
||||
:class="{ 'global-browser-toolbar--draggable': !maximized }"
|
||||
@mousedown="onToolbarMouseDown"
|
||||
>
|
||||
<div
|
||||
v-if="!maximized"
|
||||
class="global-browser-drag-strip flex-shrink-0"
|
||||
title="拖动"
|
||||
@mousedown="onToolbarMouseDown"
|
||||
>
|
||||
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
|
||||
</div>
|
||||
|
||||
<v-tabs
|
||||
:model-value="activeTabId ?? undefined"
|
||||
density="compact"
|
||||
show-arrows
|
||||
class="global-browser-tabs flex-grow-1 min-w-0"
|
||||
@update:model-value="onTabSelected"
|
||||
@mousedown.stop
|
||||
>
|
||||
<v-tab v-for="tab in tabs" :key="tab.id" :value="tab.id" class="text-none px-2">
|
||||
<span class="text-truncate" style="max-width: 120px">{{ tabLabel(tab) }}</span>
|
||||
<v-btn
|
||||
v-if="tabs.length > 1"
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-1 opacity-60"
|
||||
density="compact"
|
||||
@click.stop="closeTab(tab.id)"
|
||||
/>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-plus"
|
||||
title="新标签"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="newEmptyTab"
|
||||
/>
|
||||
|
||||
<v-divider vertical class="mx-1" style="height: 20px; align-self: center" @mousedown.stop />
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-history"
|
||||
title="浏览记录"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="showHistory = !showHistory"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-restart"
|
||||
title="重启浏览器(保留历史记录)"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="onRestart"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:icon="maximized ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
|
||||
density="compact"
|
||||
@mousedown.stop
|
||||
@click="maximized = !maximized"
|
||||
/>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
icon="mdi-window-minimize"
|
||||
title="最小化(可拖动)"
|
||||
density="compact"
|
||||
class="mr-1"
|
||||
@mousedown.stop
|
||||
@click="minimizePanel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="global-browser-nav d-flex align-center px-2 py-1 flex-shrink-0 border-b">
|
||||
<v-btn icon="mdi-arrow-left" variant="text" size="small" :disabled="!canGoBack" @click="goBack" />
|
||||
<v-btn icon="mdi-arrow-right" variant="text" size="small" :disabled="!canGoForward" @click="goForward" />
|
||||
<v-btn icon="mdi-refresh" variant="text" size="small" :disabled="!activeTab?.url" @click="refreshActive" />
|
||||
<v-text-field
|
||||
v-model="addressDraft"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
placeholder="https://example.com"
|
||||
prepend-inner-icon="mdi-web"
|
||||
class="global-browser-url mx-2"
|
||||
@keydown.enter.prevent="onGo"
|
||||
/>
|
||||
<v-btn color="primary" variant="flat" size="small" class="text-none" @click="onGo">打开</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-open-in-new"
|
||||
variant="text"
|
||||
size="small"
|
||||
:disabled="!activeTab?.url"
|
||||
@click="openExternal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="lastError"
|
||||
type="warning"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
class="ma-2 mb-0 flex-shrink-0"
|
||||
closable
|
||||
@click:close="lastError = null"
|
||||
>
|
||||
{{ lastError }}
|
||||
</v-alert>
|
||||
|
||||
<div class="global-browser-main d-flex flex-grow-1 min-h-0">
|
||||
<aside v-if="showHistory" class="global-browser-history flex-shrink-0 border-e">
|
||||
<div class="text-caption font-weight-medium px-3 py-2">浏览记录(已同步账号)</div>
|
||||
<v-list density="compact" nav class="py-0 global-browser-history-list">
|
||||
<v-list-item
|
||||
v-for="(visit, idx) in visits"
|
||||
:key="`${visit.at}-${idx}`"
|
||||
:title="visit.title"
|
||||
:subtitle="visit.url"
|
||||
@click="openFromHistory(visit.url)"
|
||||
/>
|
||||
<v-list-item v-if="!visits.length" title="暂无记录" disabled />
|
||||
</v-list>
|
||||
</aside>
|
||||
|
||||
<div class="global-browser-content flex-grow-1 d-flex flex-column min-w-0 min-h-0">
|
||||
<div
|
||||
v-if="!activeTab?.url"
|
||||
class="flex-grow-1 d-flex flex-column align-center justify-center text-medium-emphasis pa-4"
|
||||
>
|
||||
<v-icon icon="mdi-web" size="48" class="mb-3 opacity-50" />
|
||||
<p class="text-body-2 text-center">输入地址后按回车或点「打开」</p>
|
||||
<p class="text-caption mt-2 text-center">
|
||||
浏览器不可关闭,仅可最小化或重启;页面在您当前的 Chrome / Firefox 引擎中渲染。
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<iframe
|
||||
:key="activeTab.frameKey"
|
||||
:src="activeTab.url"
|
||||
class="global-browser-frame flex-grow-1"
|
||||
title="内置浏览器"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis px-2 py-1 flex-shrink-0 global-browser-hint">
|
||||
若空白,可能禁止 iframe 嵌入 — 请用「新标签打开」。
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!maximized"
|
||||
class="global-browser-resize"
|
||||
title="拖动调整大小"
|
||||
@mousedown.stop="onResizeStart"
|
||||
/>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Minimized — draggable floating bar (any corner) -->
|
||||
<div
|
||||
v-if="minimized && tabs.length"
|
||||
class="global-browser-mini-float"
|
||||
:style="miniPanelStyle"
|
||||
@mousedown="onMiniFloatMouseDown"
|
||||
>
|
||||
<v-card border rounded="lg" class="global-browser-mini-panel h-100 d-flex flex-column">
|
||||
<div
|
||||
class="global-browser-mini-toolbar d-flex align-center px-2 flex-grow-1 global-browser-toolbar--draggable"
|
||||
@dblclick.stop="openPanel"
|
||||
>
|
||||
<div
|
||||
class="global-browser-drag-strip global-browser-drag-strip--mini flex-shrink-0"
|
||||
title="拖动"
|
||||
@mousedown.stop="onMiniDragStart"
|
||||
>
|
||||
<v-icon size="16" class="text-disabled">mdi-drag</v-icon>
|
||||
</div>
|
||||
<v-icon icon="mdi-web" size="18" class="mr-2 flex-shrink-0" />
|
||||
<span
|
||||
class="text-caption text-truncate flex-grow-1 min-w-0 global-browser-mini-title"
|
||||
@click="onMiniTitleClick"
|
||||
>
|
||||
{{ activeTab ? tabLabel(activeTab) : '浏览器' }}
|
||||
</span>
|
||||
<v-chip v-if="tabs.length > 1" size="x-small" class="mx-1 flex-shrink-0">{{ tabs.length }}</v-chip>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-restart"
|
||||
title="重启浏览器"
|
||||
@mousedown.stop
|
||||
@click.stop="onRestart"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-dock-window"
|
||||
title="展开"
|
||||
@mousedown.stop
|
||||
@click.stop="openPanel"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
v-if="showLauncher"
|
||||
class="global-browser-launcher"
|
||||
color="primary"
|
||||
icon="mdi-web"
|
||||
size="large"
|
||||
elevation="4"
|
||||
title="展开浏览器"
|
||||
@click="onLauncherClick"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<v-dialog v-model="showRestartConfirm" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>重启浏览器?</v-card-title>
|
||||
<v-card-text>将关闭所有标签并打开空白页,浏览历史记录会保留。</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showRestartConfirm = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="confirmRestart">重启</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useFloatingPanel, type FloatRect } from '@/composables/useFloatingPanel'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
|
||||
defineProps<{ showLauncher: boolean }>()
|
||||
|
||||
const showHistory = ref(false)
|
||||
const showRestartConfirm = ref(false)
|
||||
const maximized = ref(false)
|
||||
|
||||
const {
|
||||
panelOpen,
|
||||
minimized,
|
||||
lastError,
|
||||
addressDraft,
|
||||
visits,
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
panelRect,
|
||||
minimizedRect,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
tabLabel,
|
||||
openPanel,
|
||||
minimizePanel,
|
||||
restartBrowser,
|
||||
navigate,
|
||||
refreshActive,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newEmptyTab,
|
||||
openFromHistory,
|
||||
savePanelRect,
|
||||
saveMinimizedRect,
|
||||
bootstrap,
|
||||
} = useGlobalBrowser()
|
||||
|
||||
function defaultMiniRect(): FloatRect {
|
||||
const w = 320
|
||||
const h = 52
|
||||
return {
|
||||
x: Math.max(16, window.innerWidth - w - 16),
|
||||
y: Math.max(16, window.innerHeight - h - 16),
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
|
||||
const panelFallback: FloatRect = { x: 80, y: 72, w: 980, h: 640 }
|
||||
const { rect, panelStyle, startDrag, startResize, centerOnScreen } = useFloatingPanel(
|
||||
'nexus_global_browser_rect_v1',
|
||||
panelFallback,
|
||||
)
|
||||
|
||||
const {
|
||||
rect: miniRect,
|
||||
panelStyle: miniPanelStyle,
|
||||
startDrag: startMiniDrag,
|
||||
} = useFloatingPanel('nexus_global_browser_mini_rect_v1', defaultMiniRect(), {
|
||||
minW: 200,
|
||||
minH: 44,
|
||||
maxW: 520,
|
||||
maxH: 56,
|
||||
})
|
||||
|
||||
let miniDragMoved = false
|
||||
let suppressMiniTitleClick = false
|
||||
|
||||
function seedRectFromServer() {
|
||||
if (panelRect.value) {
|
||||
rect.value = { ...panelRect.value }
|
||||
} else {
|
||||
centerOnScreen()
|
||||
}
|
||||
if (minimizedRect.value) {
|
||||
miniRect.value = { ...minimizedRect.value }
|
||||
} else {
|
||||
miniRect.value = defaultMiniRect()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await bootstrap()
|
||||
seedRectFromServer()
|
||||
})
|
||||
|
||||
watch(rect, (value) => {
|
||||
savePanelRect({ ...value })
|
||||
}, { deep: true })
|
||||
|
||||
watch(miniRect, (value) => {
|
||||
saveMinimizedRect({ ...value })
|
||||
}, { deep: true })
|
||||
|
||||
function isDragBlockedTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return true
|
||||
return Boolean(target.closest('button, .v-tab, .v-btn, input, a, .v-field, .v-chip'))
|
||||
}
|
||||
|
||||
function onToolbarMouseDown(e: MouseEvent) {
|
||||
if (maximized.value) return
|
||||
if (isDragBlockedTarget(e.target)) return
|
||||
e.preventDefault()
|
||||
startDrag(e)
|
||||
}
|
||||
|
||||
function onMiniDragStart(e: MouseEvent) {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
miniDragMoved = false
|
||||
const originX = e.clientX
|
||||
const originY = e.clientY
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (Math.abs(ev.clientX - originX) + Math.abs(ev.clientY - originY) > 4) {
|
||||
miniDragMoved = true
|
||||
}
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
suppressMiniTitleClick = miniDragMoved
|
||||
if (miniDragMoved) {
|
||||
window.setTimeout(() => {
|
||||
suppressMiniTitleClick = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
startMiniDrag(e)
|
||||
}
|
||||
|
||||
function onMiniFloatMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (isDragBlockedTarget(e.target)) return
|
||||
if ((e.target as HTMLElement).closest('.global-browser-drag-strip')) return
|
||||
e.preventDefault()
|
||||
onMiniDragStart(e)
|
||||
}
|
||||
|
||||
function onResizeStart(e: MouseEvent) {
|
||||
startResize(e)
|
||||
}
|
||||
|
||||
function onTabSelected(id: unknown) {
|
||||
if (typeof id === 'string') switchTab(id)
|
||||
}
|
||||
|
||||
function onGo() {
|
||||
navigate(addressDraft.value)
|
||||
}
|
||||
|
||||
function onLauncherClick() {
|
||||
if (tabs.value.length) {
|
||||
openPanel()
|
||||
return
|
||||
}
|
||||
newEmptyTab()
|
||||
}
|
||||
|
||||
function onMiniTitleClick() {
|
||||
if (suppressMiniTitleClick) return
|
||||
openPanel()
|
||||
}
|
||||
|
||||
function onRestart() {
|
||||
showRestartConfirm.value = true
|
||||
}
|
||||
|
||||
function confirmRestart() {
|
||||
showRestartConfirm.value = false
|
||||
restartBrowser()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-browser-float,
|
||||
.global-browser-mini-float {
|
||||
position: fixed;
|
||||
z-index: 2350;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
touch-action: none;
|
||||
}
|
||||
.global-browser-float--maximized {
|
||||
inset: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
.global-browser-panel,
|
||||
.global-browser-mini-panel {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-toolbar,
|
||||
.global-browser-mini-toolbar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.global-browser-mini-toolbar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
.global-browser-toolbar--draggable {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
.global-browser-drag-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 40px;
|
||||
margin-left: 4px;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
.global-browser-drag-strip--mini {
|
||||
width: 24px;
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.global-browser-mini-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
.global-browser-nav {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-url {
|
||||
flex: 1 1 auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
.global-browser-main {
|
||||
min-height: 0;
|
||||
}
|
||||
.global-browser-history {
|
||||
width: 280px;
|
||||
max-width: 40%;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-history-list {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.global-browser-frame {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.global-browser-hint {
|
||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
.global-browser-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 3;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 50%,
|
||||
rgba(var(--v-theme-on-surface), 0.25) 50%
|
||||
);
|
||||
}
|
||||
.global-browser-launcher {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2340;
|
||||
}
|
||||
</style>
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" max-width="520" persistent @update:model-value="emit('update:modelValue', $event)">
|
||||
<v-card border>
|
||||
<v-card-title>{{ editingId ? '编辑 RDP 主机' : '添加 RDP 主机' }}</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field
|
||||
v-model="localForm.name"
|
||||
label="名称"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[required('名称')]"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="localForm.host"
|
||||
label="IP / 域名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
placeholder="例:192.168.1.100"
|
||||
:rules="[required('IP 或域名')]"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-row dense>
|
||||
<v-col cols="5">
|
||||
<v-text-field
|
||||
v-model.number="localForm.port"
|
||||
label="端口"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="7">
|
||||
<v-text-field
|
||||
v-model="localForm.username"
|
||||
label="用户名"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:rules="[required('用户名')]"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-text-field
|
||||
v-model="localForm.password"
|
||||
label="密码"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:placeholder="editingId && passwordSet ? '留空则不修改' : ''"
|
||||
:hint="editingId && passwordSet ? '已设置密码;留空保持不变' : '连接远程桌面必需'"
|
||||
persistent-hint
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="localForm.description"
|
||||
label="备注(可选)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="2"
|
||||
auto-grow
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-divider />
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="saving" @click="emit('update:modelValue', false)">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" :loading="saving" @click="onSave">保存</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { required } from '@/utils/validation'
|
||||
import type { RdpHostFormModel } from '@/composables/rdp/useRdpHostList'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
form: RdpHostFormModel
|
||||
editingId: number | null
|
||||
passwordSet: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
save: [form: RdpHostFormModel]
|
||||
}>()
|
||||
|
||||
const localForm = ref<RdpHostFormModel>({ ...props.form })
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) localForm.value = { ...props.form }
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.form,
|
||||
(f) => {
|
||||
if (props.modelValue) localForm.value = { ...f }
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function onSave() {
|
||||
const f = localForm.value
|
||||
if (!f.name.trim() || !f.host.trim() || !f.username.trim()) return
|
||||
if (!props.editingId && !f.password.trim()) return
|
||||
emit('save', { ...f })
|
||||
}
|
||||
</script>
|
||||
@@ -491,12 +491,19 @@ export function useFilesActions(options: {
|
||||
for (const f of uploadFiles.value) form.append('file', f)
|
||||
form.append('server_id', String(selectedServer.value))
|
||||
form.append('remote_path', currentPath.value)
|
||||
await http.upload('/sync/upload', form)
|
||||
const res = await http.upload<{
|
||||
permission_fixup?: { applied?: boolean; error?: string | null }
|
||||
}>('/sync/upload', form)
|
||||
showUpload.value = false
|
||||
uploadFiles.value = []
|
||||
options.invalidateAfterMutation()
|
||||
await options.browse({ force: true })
|
||||
snackbar('上传成功')
|
||||
const pf = res?.permission_fixup
|
||||
if (pf?.error) {
|
||||
snackbar(`上传成功,但权限修正失败: ${pf.error}`, 'warning')
|
||||
} else {
|
||||
snackbar('上传成功')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '上传失败'
|
||||
snackbar(msg, 'error')
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
|
||||
export interface RdpHostItem {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password_set: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface RdpHostFormModel {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function emptyRdpHostForm(): RdpHostFormModel {
|
||||
return {
|
||||
name: '',
|
||||
host: '',
|
||||
port: 3389,
|
||||
username: 'Administrator',
|
||||
password: '',
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function useRdpHostList() {
|
||||
const snackbar = useSnackbar()
|
||||
const hosts = ref<RdpHostItem[]>([])
|
||||
const search = ref('')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const filteredHosts = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return hosts.value
|
||||
return hosts.value.filter(
|
||||
(h) =>
|
||||
h.name.toLowerCase().includes(q)
|
||||
|| h.host.toLowerCase().includes(q)
|
||||
|| h.username.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
async function loadHosts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await http.get<{ items: RdpHostItem[] }>('/rdp/hosts/')
|
||||
hosts.value = res.items || []
|
||||
} catch (e) {
|
||||
hosts.value = []
|
||||
snackbar(formatApiError(e, '加载 RDP 主机列表失败'), 'warning')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createHost(form: RdpHostFormModel): Promise<RdpHostItem | null> {
|
||||
saving.value = true
|
||||
try {
|
||||
const created = await http.post<RdpHostItem>('/rdp/hosts/', {
|
||||
name: form.name.trim(),
|
||||
host: form.host.trim(),
|
||||
port: Number(form.port) || 3389,
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
description: form.description.trim() || undefined,
|
||||
})
|
||||
await loadHosts()
|
||||
snackbar('已添加 RDP 主机', 'success')
|
||||
return created
|
||||
} catch (e) {
|
||||
snackbar(formatApiError(e, '添加失败'), 'error')
|
||||
return null
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateHost(id: number, form: RdpHostFormModel, passwordSet: boolean): Promise<boolean> {
|
||||
saving.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
host: form.host.trim(),
|
||||
port: Number(form.port) || 3389,
|
||||
username: form.username.trim(),
|
||||
description: form.description.trim() || '',
|
||||
}
|
||||
if (form.password.trim()) {
|
||||
body.password = form.password.trim()
|
||||
} else if (!passwordSet) {
|
||||
snackbar('请填写 RDP 密码', 'error')
|
||||
return false
|
||||
}
|
||||
await http.put(`/rdp/hosts/${id}`, body)
|
||||
await loadHosts()
|
||||
snackbar('已保存', 'success')
|
||||
return true
|
||||
} catch (e) {
|
||||
snackbar(formatApiError(e, '保存失败'), 'error')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHost(id: number, name: string): Promise<boolean> {
|
||||
saving.value = true
|
||||
try {
|
||||
await http.delete(`/rdp/hosts/${id}`)
|
||||
await loadHosts()
|
||||
snackbar(`已删除「${name}」`, 'success')
|
||||
return true
|
||||
} catch (e) {
|
||||
snackbar(formatApiError(e, '删除失败'), 'error')
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hosts,
|
||||
search,
|
||||
filteredHosts,
|
||||
loading,
|
||||
saving,
|
||||
loadHosts,
|
||||
createHost,
|
||||
updateHost,
|
||||
deleteHost,
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { ref, shallowRef, onBeforeUnmount } from 'vue'
|
||||
import Guacamole from 'guacamole-common-js'
|
||||
import { http } from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { buildWebSocketUrl } from '@/utils/wsUrl'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
|
||||
export type RdpSessionStatus = 'idle' | 'loading' | 'connecting' | 'connected' | 'error' | 'disconnected'
|
||||
|
||||
export interface RdpConnectInfo {
|
||||
host_id: number
|
||||
name: string
|
||||
hostname: string
|
||||
port: number
|
||||
username: string
|
||||
}
|
||||
|
||||
/** Guacamole connect query — credentials stay server-side (rdp_hosts). */
|
||||
function buildConnectString(info: RdpConnectInfo): string {
|
||||
const pairs: [string, string][] = [
|
||||
['hostname', info.hostname],
|
||||
['port', String(info.port)],
|
||||
['username', info.username],
|
||||
['security', 'any'],
|
||||
['ignore-cert', 'true'],
|
||||
['disable-wallpaper', 'true'],
|
||||
['disable-theming', 'true'],
|
||||
['enable-font-smoothing', 'true'],
|
||||
]
|
||||
return pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
||||
}
|
||||
|
||||
export function useRdpSession() {
|
||||
const status = ref<RdpSessionStatus>('idle')
|
||||
const errorMessage = ref('')
|
||||
const serverName = ref('')
|
||||
const displayEl = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
let client: InstanceType<typeof Guacamole.Client> | null = null
|
||||
let tunnel: InstanceType<typeof Guacamole.WebSocketTunnel> | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let mountTarget: HTMLElement | null = null
|
||||
|
||||
function detachDisplay() {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
if (mountTarget && displayEl.value?.parentElement === mountTarget) {
|
||||
mountTarget.innerHTML = ''
|
||||
}
|
||||
displayEl.value = null
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
detachDisplay()
|
||||
try {
|
||||
client?.disconnect()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
client = null
|
||||
tunnel = null
|
||||
if (status.value !== 'error') {
|
||||
status.value = 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
function sendDisplaySize() {
|
||||
if (!client || !mountTarget) return
|
||||
const w = Math.max(640, mountTarget.clientWidth || 1024)
|
||||
const h = Math.max(480, mountTarget.clientHeight || 768)
|
||||
client.sendSize(w, h)
|
||||
}
|
||||
|
||||
async function connect(hostId: number, container: HTMLElement) {
|
||||
disconnect()
|
||||
mountTarget = container
|
||||
status.value = 'loading'
|
||||
errorMessage.value = ''
|
||||
|
||||
const auth = useAuthStore()
|
||||
if (!auth.token) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = '未登录'
|
||||
return
|
||||
}
|
||||
|
||||
let info: RdpConnectInfo
|
||||
try {
|
||||
info = await http.get<RdpConnectInfo>(`/rdp/hosts/${hostId}/connect`)
|
||||
} catch (e) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = formatApiError(e, '加载 RDP 连接参数失败')
|
||||
return
|
||||
}
|
||||
|
||||
serverName.value = info.name
|
||||
status.value = 'connecting'
|
||||
|
||||
// Token in path: Guacamole Client.connect() appends ?hostname=… to tunnel URL.
|
||||
const wsUrl = buildWebSocketUrl(
|
||||
`/ws/rdp/hosts/${hostId}/tunnel/${encodeURIComponent(auth.token)}`,
|
||||
)
|
||||
tunnel = new Guacamole.WebSocketTunnel(wsUrl)
|
||||
const rdpClient = new Guacamole.Client(tunnel)
|
||||
client = rdpClient
|
||||
|
||||
rdpClient.onerror = (st: { code?: number; message?: string }) => {
|
||||
status.value = 'error'
|
||||
errorMessage.value = st?.message || `RDP 错误 (${st?.code ?? 'unknown'})`
|
||||
disconnect()
|
||||
}
|
||||
|
||||
rdpClient.onstatechange = (state: number) => {
|
||||
if (state === Guacamole.Client.STATE_CONNECTED) {
|
||||
status.value = 'connected'
|
||||
const el = rdpClient.getDisplay().getElement()
|
||||
if (el && mountTarget) {
|
||||
mountTarget.innerHTML = ''
|
||||
mountTarget.appendChild(el)
|
||||
displayEl.value = el
|
||||
sendDisplaySize()
|
||||
resizeObserver = new ResizeObserver(() => sendDisplaySize())
|
||||
resizeObserver.observe(mountTarget)
|
||||
}
|
||||
} else if (state === Guacamole.Client.STATE_DISCONNECTED) {
|
||||
if (status.value !== 'error') status.value = 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
const display = rdpClient.getDisplay()
|
||||
display.onresize = () => sendDisplaySize()
|
||||
|
||||
rdpClient.connect(buildConnectString(info))
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => disconnect())
|
||||
|
||||
return {
|
||||
status,
|
||||
errorMessage,
|
||||
serverName,
|
||||
connect,
|
||||
disconnect,
|
||||
sendDisplaySize,
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,28 @@ function saveRect(key: string, rect: FloatRect) {
|
||||
}
|
||||
}
|
||||
|
||||
export interface FloatingPanelOptions {
|
||||
/** Minimum width when clamping (default 480) */
|
||||
minW?: number
|
||||
/** Minimum height when clamping (default 320) */
|
||||
minH?: number
|
||||
/** Maximum width cap (default 1400) */
|
||||
maxW?: number
|
||||
/** Maximum height cap (default 900) */
|
||||
maxH?: number
|
||||
}
|
||||
|
||||
/** 可拖动浮动面板位置与尺寸(持久化到 localStorage) */
|
||||
export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
|
||||
export function useFloatingPanel(
|
||||
storageKey: string,
|
||||
fallback: FloatRect,
|
||||
options: FloatingPanelOptions = {},
|
||||
) {
|
||||
const minW = options.minW ?? 480
|
||||
const minH = options.minH ?? 320
|
||||
const maxWCap = options.maxW ?? 1400
|
||||
const maxHCap = options.maxH ?? 900
|
||||
|
||||
const rect = ref<FloatRect>(loadRect(storageKey, fallback))
|
||||
|
||||
let dragOrigin = { px: 0, py: 0, rx: 0, ry: 0 }
|
||||
@@ -51,18 +71,18 @@ export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
|
||||
|
||||
function clampToViewport() {
|
||||
const margin = 8
|
||||
const maxW = Math.min(window.innerWidth - margin * 2, 1400)
|
||||
const maxH = Math.min(window.innerHeight - margin * 2, 900)
|
||||
rect.value.w = Math.max(480, Math.min(rect.value.w, maxW))
|
||||
rect.value.h = Math.max(320, Math.min(rect.value.h, maxH))
|
||||
const maxW = Math.min(window.innerWidth - margin * 2, maxWCap)
|
||||
const maxH = Math.min(window.innerHeight - margin * 2, maxHCap)
|
||||
rect.value.w = Math.max(minW, Math.min(rect.value.w, maxW))
|
||||
rect.value.h = Math.max(minH, Math.min(rect.value.h, maxH))
|
||||
rect.value.x = Math.max(margin, Math.min(rect.value.x, window.innerWidth - rect.value.w - margin))
|
||||
rect.value.y = Math.max(margin, Math.min(rect.value.y, window.innerHeight - rect.value.h - margin))
|
||||
}
|
||||
|
||||
function onDragMove(e: MouseEvent) {
|
||||
if (resizing) {
|
||||
rect.value.w = Math.max(480, resizeOrigin.rw + e.clientX - resizeOrigin.px)
|
||||
rect.value.h = Math.max(320, resizeOrigin.rh + e.clientY - resizeOrigin.py)
|
||||
rect.value.w = Math.max(minW, resizeOrigin.rw + e.clientX - resizeOrigin.px)
|
||||
rect.value.h = Math.max(minH, resizeOrigin.rh + e.clientY - resizeOrigin.py)
|
||||
clampToViewport()
|
||||
return
|
||||
}
|
||||
@@ -80,6 +100,7 @@ export function useFloatingPanel(storageKey: string, fallback: FloatRect) {
|
||||
|
||||
function startDrag(e: MouseEvent) {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
dragOrigin = { px: e.clientX, py: e.clientY, rx: rect.value.x, ry: rect.value.y }
|
||||
window.addEventListener('mousemove', onDragMove)
|
||||
window.addEventListener('mouseup', onDragEnd)
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Global floating browser — persists tabs/history to MySQL via /api/browser/state.
|
||||
* Singleton module state survives route changes.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
import { normalizeBrowserUrl } from '@/utils/browserUrl'
|
||||
import type { FloatRect } from '@/composables/useFloatingPanel'
|
||||
|
||||
const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1'
|
||||
const SAVE_DEBOUNCE_MS = 700
|
||||
|
||||
export interface BrowserVisit {
|
||||
url: string
|
||||
title: string
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface BrowserTab {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
stack: string[]
|
||||
stackIndex: number
|
||||
frameKey: number
|
||||
}
|
||||
|
||||
export interface BrowserStateResponse {
|
||||
url_history: string[]
|
||||
visits: BrowserVisit[]
|
||||
tabs: Array<{
|
||||
id: string
|
||||
url: string
|
||||
title?: string
|
||||
stack?: string[]
|
||||
stack_index?: number
|
||||
}>
|
||||
active_tab_id: string | null
|
||||
minimized: boolean
|
||||
rect: FloatRect | null
|
||||
minimized_rect: FloatRect | null
|
||||
}
|
||||
|
||||
const bootstrapped = ref(false)
|
||||
const saving = ref(false)
|
||||
const panelOpen = ref(false)
|
||||
const minimized = ref(false)
|
||||
const maximized = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
const addressDraft = ref('')
|
||||
const urlHistory = ref<string[]>([])
|
||||
const visits = ref<BrowserVisit[]>([])
|
||||
const tabs = ref<BrowserTab[]>([])
|
||||
const activeTabId = ref<string | null>(null)
|
||||
const panelRect = ref<FloatRect | null>(null)
|
||||
const minimizedRect = ref<FloatRect | null>(null)
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function tabLabel(tab: BrowserTab): string {
|
||||
try {
|
||||
return tab.title || new URL(tab.url).hostname
|
||||
} catch {
|
||||
return tab.title || tab.url
|
||||
}
|
||||
}
|
||||
|
||||
function newTabId(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
function serializeTabs(): BrowserStateResponse['tabs'] {
|
||||
return tabs.value.map((t) => ({
|
||||
id: t.id,
|
||||
url: t.url,
|
||||
title: t.title,
|
||||
stack: t.stack,
|
||||
stack_index: t.stackIndex,
|
||||
}))
|
||||
}
|
||||
|
||||
function applyServerState(data: BrowserStateResponse) {
|
||||
urlHistory.value = data.url_history || []
|
||||
visits.value = (data.visits || []).map((v) => ({
|
||||
url: v.url,
|
||||
title: v.title || v.url,
|
||||
at: v.at,
|
||||
}))
|
||||
tabs.value = (data.tabs || []).map((t) => ({
|
||||
id: t.id,
|
||||
url: t.url,
|
||||
title: t.title || t.url,
|
||||
stack: t.stack?.length ? t.stack : [t.url],
|
||||
stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1,
|
||||
frameKey: 0,
|
||||
}))
|
||||
activeTabId.value = data.active_tab_id
|
||||
if (!activeTabId.value && tabs.value.length) {
|
||||
activeTabId.value = tabs.value[0]!.id
|
||||
}
|
||||
minimized.value = Boolean(data.minimized)
|
||||
panelRect.value = data.rect
|
||||
minimizedRect.value = data.minimized_rect
|
||||
panelOpen.value = tabs.value.length > 0 && !minimized.value
|
||||
syncAddressFromActive()
|
||||
}
|
||||
|
||||
function syncAddressFromActive() {
|
||||
const tab = activeTab.value
|
||||
addressDraft.value = tab?.url ?? ''
|
||||
}
|
||||
|
||||
const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null)
|
||||
|
||||
const isActive = computed(() => tabs.value.length > 0 || panelOpen.value)
|
||||
|
||||
const canGoBack = computed(() => {
|
||||
const tab = activeTab.value
|
||||
return tab ? tab.stackIndex > 0 : false
|
||||
})
|
||||
|
||||
const canGoForward = computed(() => {
|
||||
const tab = activeTab.value
|
||||
return tab ? tab.stackIndex < tab.stack.length - 1 : false
|
||||
})
|
||||
|
||||
function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
void persistState(extra)
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) {
|
||||
saving.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
tabs: serializeTabs(),
|
||||
active_tab_id: activeTabId.value,
|
||||
minimized: minimized.value,
|
||||
rect: panelRect.value,
|
||||
minimized_rect: minimizedRect.value,
|
||||
}
|
||||
if (extra?.recordUrl) {
|
||||
body.record_url = extra.recordUrl
|
||||
if (extra.recordTitle) body.record_title = extra.recordTitle
|
||||
} else {
|
||||
body.url_history = urlHistory.value
|
||||
body.visits = visits.value
|
||||
}
|
||||
const res = await http.put<BrowserStateResponse>('/browser/state', body)
|
||||
applyServerState(res)
|
||||
} catch {
|
||||
/* keep local state */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateLegacyHistory() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return
|
||||
const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u))
|
||||
if (!urls.length) return
|
||||
for (const url of [...urls].reverse()) {
|
||||
const normalized = normalizeBrowserUrl(url)
|
||||
if (normalized) {
|
||||
await persistState({ recordUrl: normalized, recordTitle: normalized })
|
||||
}
|
||||
}
|
||||
sessionStorage.removeItem(LEGACY_HISTORY_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapped.value) return
|
||||
try {
|
||||
const res = await http.get<BrowserStateResponse>('/browser/state')
|
||||
applyServerState(res)
|
||||
if (!visits.value.length && !urlHistory.value.length) {
|
||||
await migrateLegacyHistory()
|
||||
}
|
||||
} catch {
|
||||
/* offline */
|
||||
} finally {
|
||||
bootstrapped.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
minimized.value = false
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function minimizePanel() {
|
||||
minimized.value = true
|
||||
panelOpen.value = false
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/** Reset to a single blank tab; browsing history is kept. */
|
||||
function restartBrowser() {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: '',
|
||||
title: '新标签',
|
||||
stack: [],
|
||||
stackIndex: -1,
|
||||
frameKey: 0,
|
||||
}
|
||||
tabs.value = [tab]
|
||||
activeTabId.value = tab.id
|
||||
addressDraft.value = ''
|
||||
lastError.value = null
|
||||
openPanel()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) {
|
||||
const normalized = normalizeBrowserUrl(input)
|
||||
if (!normalized) {
|
||||
lastError.value = '请输入有效的 http 或 https 地址'
|
||||
return false
|
||||
}
|
||||
lastError.value = null
|
||||
|
||||
if (opts?.newTab || !tabs.value.length || !activeTab.value) {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: normalized,
|
||||
title: opts?.title || normalized,
|
||||
stack: [normalized],
|
||||
stackIndex: 0,
|
||||
frameKey: 0,
|
||||
}
|
||||
tabs.value = [...tabs.value, tab].slice(-12)
|
||||
activeTabId.value = tab.id
|
||||
} else {
|
||||
const tab = activeTab.value
|
||||
const stack = tab.stack.slice(0, tab.stackIndex + 1)
|
||||
if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized)
|
||||
tab.url = normalized
|
||||
if (opts?.title) tab.title = opts.title
|
||||
tab.stack = stack
|
||||
tab.stackIndex = stack.length - 1
|
||||
tab.frameKey += 1
|
||||
}
|
||||
|
||||
addressDraft.value = normalized
|
||||
openPanel()
|
||||
scheduleSave({ recordUrl: normalized, recordTitle: opts?.title })
|
||||
return true
|
||||
}
|
||||
|
||||
function navigate(input: string) {
|
||||
return openUrl(input)
|
||||
}
|
||||
|
||||
function refreshActive() {
|
||||
const tab = activeTab.value
|
||||
if (!tab) return
|
||||
tab.frameKey += 1
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const tab = activeTab.value
|
||||
if (!tab || tab.stackIndex <= 0) return
|
||||
tab.stackIndex -= 1
|
||||
tab.url = tab.stack[tab.stackIndex]!
|
||||
tab.frameKey += 1
|
||||
addressDraft.value = tab.url
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
const tab = activeTab.value
|
||||
if (!tab || tab.stackIndex >= tab.stack.length - 1) return
|
||||
tab.stackIndex += 1
|
||||
tab.url = tab.stack[tab.stackIndex]!
|
||||
tab.frameKey += 1
|
||||
addressDraft.value = tab.url
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
const tab = activeTab.value
|
||||
if (!tab) return
|
||||
window.open(tab.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function switchTab(id: string) {
|
||||
activeTabId.value = id
|
||||
syncAddressFromActive()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function closeTab(id: string) {
|
||||
if (tabs.value.length <= 1) return
|
||||
const idx = tabs.value.findIndex((t) => t.id === id)
|
||||
if (idx < 0) return
|
||||
const next = tabs.value.filter((t) => t.id !== id)
|
||||
tabs.value = next
|
||||
if (activeTabId.value === id) {
|
||||
activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null
|
||||
}
|
||||
syncAddressFromActive()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function newEmptyTab() {
|
||||
const tab: BrowserTab = {
|
||||
id: newTabId(),
|
||||
url: '',
|
||||
title: '新标签',
|
||||
stack: [],
|
||||
stackIndex: -1,
|
||||
frameKey: 0,
|
||||
}
|
||||
tabs.value = [...tabs.value, tab].slice(-12)
|
||||
activeTabId.value = tab.id
|
||||
addressDraft.value = ''
|
||||
openPanel()
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function openFromHistory(url: string) {
|
||||
openUrl(url)
|
||||
}
|
||||
|
||||
function savePanelRect(rect: FloatRect) {
|
||||
panelRect.value = rect
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function saveMinimizedRect(rect: FloatRect) {
|
||||
minimizedRect.value = rect
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
export function useGlobalBrowser() {
|
||||
return {
|
||||
bootstrapped,
|
||||
saving,
|
||||
panelOpen,
|
||||
minimized,
|
||||
maximized,
|
||||
lastError,
|
||||
addressDraft,
|
||||
urlHistory,
|
||||
visits,
|
||||
tabs,
|
||||
activeTabId,
|
||||
activeTab,
|
||||
panelRect,
|
||||
minimizedRect,
|
||||
isActive,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
tabLabel,
|
||||
bootstrap,
|
||||
openPanel,
|
||||
minimizePanel,
|
||||
restartBrowser,
|
||||
openUrl,
|
||||
navigate,
|
||||
refreshActive,
|
||||
goBack,
|
||||
goForward,
|
||||
openExternal,
|
||||
switchTab,
|
||||
closeTab,
|
||||
newEmptyTab,
|
||||
openFromHistory,
|
||||
savePanelRect,
|
||||
saveMinimizedRect,
|
||||
scheduleSave,
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ const ROUTE_CHUNKS: Record<string, () => Promise<unknown>> = {
|
||||
'/': () => import('@/pages/DashboardPage.vue'),
|
||||
'/servers': () => import('@/pages/ServersPage.vue'),
|
||||
'/terminal': () => import('@/pages/TerminalPage.vue'),
|
||||
'/rdp': () => import('@/pages/RdpPage.vue'),
|
||||
'/files': () => import('@/pages/FilesPage.vue'),
|
||||
'/push': () => import('@/pages/PushPage.vue'),
|
||||
'/scripts': () => import('@/pages/ScriptsPage.vue'),
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useServerPagination(options?: UseServerPaginationOptions) {
|
||||
const sortBy = ref<TableSortItem[]>([])
|
||||
|
||||
function listQueryParams(): Record<string, unknown> {
|
||||
const q = search.value.trim()
|
||||
const q = String(search.value ?? '').trim()
|
||||
// 有搜索词时全局检索:忽略分类/在线/路径分区,避免刚添加的机器搜不到
|
||||
const applyPathFilter = targetPathUnset !== undefined && !q
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ref } from 'vue'
|
||||
import { http } from '@/api'
|
||||
|
||||
const LEGACY_HISTORY = 'nexus_servers_search_history'
|
||||
const LEGACY_LAST = 'nexus_servers_search_last'
|
||||
|
||||
export interface ServerSearchHistoryResponse {
|
||||
history: string[]
|
||||
last: string | null
|
||||
}
|
||||
|
||||
function readLegacyLocal(): { history: string[]; last: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_HISTORY)
|
||||
const last = localStorage.getItem(LEGACY_LAST) || ''
|
||||
if (!raw) return last.trim() ? { history: [], last } : null
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return last.trim() ? { history: [], last } : null
|
||||
const history = parsed.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||
if (!history.length && !last.trim()) return null
|
||||
return { history, last }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegacyLocal() {
|
||||
try {
|
||||
localStorage.removeItem(LEGACY_HISTORY)
|
||||
localStorage.removeItem(LEGACY_LAST)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Servers page search history — persisted per admin in MySQL. */
|
||||
export function useServerSearchHistory() {
|
||||
const items = ref<string[]>([])
|
||||
const bootstrapped = ref(false)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const res = await http.get<ServerSearchHistoryResponse>('/servers/search-history')
|
||||
items.value = res.history || []
|
||||
}
|
||||
|
||||
async function migrateLegacyIfEmpty(): Promise<boolean> {
|
||||
const legacy = readLegacyLocal()
|
||||
if (!legacy) return false
|
||||
if (items.value.length > 0) {
|
||||
clearLegacyLocal()
|
||||
return false
|
||||
}
|
||||
await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
|
||||
history: legacy.history,
|
||||
last: legacy.last || legacy.history[0] || '',
|
||||
})
|
||||
clearLegacyLocal()
|
||||
return true
|
||||
}
|
||||
|
||||
/** Load dropdown history only — does not apply any search to the list. */
|
||||
async function loadHistoryOnly(): Promise<void> {
|
||||
if (bootstrapped.value) return
|
||||
try {
|
||||
await load()
|
||||
if (!items.value.length && (await migrateLegacyIfEmpty())) {
|
||||
await load()
|
||||
}
|
||||
} catch {
|
||||
// offline / auth — keep empty history
|
||||
} finally {
|
||||
bootstrapped.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function record(query: string | null | undefined) {
|
||||
const normalized = String(query ?? '').trim()
|
||||
try {
|
||||
const res = await http.put<ServerSearchHistoryResponse>('/servers/search-history', {
|
||||
query: normalized,
|
||||
})
|
||||
items.value = res.history || []
|
||||
} catch {
|
||||
// non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
return { items, bootstrapped, load, loadHistoryOnly, record }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -38,16 +38,21 @@
|
||||
<v-card-title class="d-flex align-center">
|
||||
服务器列表
|
||||
<v-spacer />
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
<v-combobox
|
||||
v-model="searchDraft"
|
||||
:items="serverSearchHistory"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
label="搜索名称/IP(含未设路径)"
|
||||
density="compact"
|
||||
hide-details
|
||||
rounded
|
||||
clearable
|
||||
:auto-select-first="false"
|
||||
style="max-width: 240px"
|
||||
variant="outlined"
|
||||
@update:model-value="onSearch"
|
||||
placeholder="输入后回车,或从历史选择"
|
||||
@update:model-value="onSearchComboboxUpdate"
|
||||
@keydown.enter.prevent="commitSearch"
|
||||
/>
|
||||
<v-btn color="primary" variant="flat" class="ml-3" prepend-icon="mdi-plus" size="small" @click="openAddServer">
|
||||
添加
|
||||
@@ -240,6 +245,15 @@
|
||||
<template #item.actions="{ item }">
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(item)">终端</v-btn>
|
||||
<v-btn
|
||||
v-if="item.domain"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
density="compact"
|
||||
@click.stop="openBrowser(item)"
|
||||
>
|
||||
站点
|
||||
</v-btn>
|
||||
<v-btn variant="text" size="x-small" density="compact" @click.stop="openFiles(item)">文件</v-btn>
|
||||
<v-btn variant="text" size="x-small" density="compact" :loading="agentDiagnoseLoadingId === item.id" @click.stop="openAgentDiagnose(item)">诊断</v-btn>
|
||||
<v-btn variant="text" size="x-small" density="compact" @click.stop="editServer(item)">编辑</v-btn>
|
||||
@@ -649,6 +663,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { http } from '@/api'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { useServerPagination } from '@/composables/useServerPagination'
|
||||
import { useServerSearchHistory } from '@/composables/useServerSearchHistory'
|
||||
import { categoryDisplayLabel, useServerCategories } from '@/composables/useServerCategories'
|
||||
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
|
||||
import { fetchPagePerPage } from '@/utils/paginatedFetch'
|
||||
@@ -656,6 +671,8 @@ import { isUnsetTargetPath } from '@/utils/serverTargetPath'
|
||||
import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
|
||||
import { normalizeServerIds } from '@/utils/serverSelection'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import { guessSiteUrlFromDomain } from '@/utils/browserUrl'
|
||||
import { useGlobalBrowser } from '@/composables/useGlobalBrowser'
|
||||
import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue'
|
||||
import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast'
|
||||
import StatCardsRow from '@/components/StatCardsRow.vue'
|
||||
@@ -721,7 +738,6 @@ onMounted(() => {
|
||||
let offBatchComplete: (() => void) | null = null
|
||||
onBeforeUnmount(() => {
|
||||
offBatchComplete?.()
|
||||
clearTimeout(searchDebounceTimer)
|
||||
})
|
||||
|
||||
defineOptions({ name: 'ServersPage' })
|
||||
@@ -730,6 +746,7 @@ const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS]
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
const router = useRouter()
|
||||
const globalBrowser = useGlobalBrowser()
|
||||
const route = useRoute()
|
||||
const showCredentialsDialog = ref(false)
|
||||
|
||||
@@ -752,15 +769,46 @@ const {
|
||||
loadAllServers, listQueryParams,
|
||||
} = useServerPagination({ targetPathUnset: false })
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>
|
||||
function onSearch() {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
const { items: serverSearchHistory, record: recordServerSearch, loadHistoryOnly: loadServerSearchHistory } =
|
||||
useServerSearchHistory()
|
||||
|
||||
/** Combobox draft; applied filter is `search` from useServerPagination. */
|
||||
const searchDraft = ref('')
|
||||
|
||||
function commitSearch() {
|
||||
const q = String(searchDraft.value ?? '').trim()
|
||||
if (q === String(search.value ?? '').trim()) return
|
||||
search.value = q
|
||||
searchDraft.value = q
|
||||
page.value = 1
|
||||
unsetPathPage.value = 1
|
||||
void recordServerSearch(q)
|
||||
void loadServers()
|
||||
void loadPendingServers(true)
|
||||
}
|
||||
|
||||
function onSearchComboboxUpdate(val: string | null) {
|
||||
searchDraft.value = val ?? ''
|
||||
if (val == null || val === '') {
|
||||
if (!String(search.value ?? '').trim()) return
|
||||
search.value = ''
|
||||
page.value = 1
|
||||
unsetPathPage.value = 1
|
||||
void recordServerSearch('')
|
||||
void loadServers()
|
||||
void loadPendingServers(true)
|
||||
}, 300)
|
||||
return
|
||||
}
|
||||
const picked = String(val).trim()
|
||||
if (serverSearchHistory.value.includes(picked)) {
|
||||
search.value = picked
|
||||
searchDraft.value = picked
|
||||
page.value = 1
|
||||
unsetPathPage.value = 1
|
||||
void recordServerSearch(picked)
|
||||
void loadServers()
|
||||
void loadPendingServers(true)
|
||||
}
|
||||
}
|
||||
|
||||
const unsetPathServers = ref<ServerApiItem[]>([])
|
||||
@@ -1037,7 +1085,7 @@ watch(search, () => {
|
||||
watch(
|
||||
() => [search.value, total.value, unsetPathTotal.value, pendingServers.value.length, loading.value] as const,
|
||||
([q, mainTotal, unsetTotal, pendingCount, isLoading]) => {
|
||||
if (!q.trim() || isLoading) return
|
||||
if (!String(q ?? '').trim() || isLoading) return
|
||||
if (mainTotal > 0 || unsetTotal > 0) return
|
||||
if (pendingCount <= 0) return
|
||||
void nextTick(() => {
|
||||
@@ -1093,7 +1141,7 @@ function showFailureDialog(errors: PollErrorItem[], message?: string) {
|
||||
async function loadPendingServers(silent = false) {
|
||||
if (!silent) pendingLoading.value = true
|
||||
try {
|
||||
const q = search.value.trim()
|
||||
const q = String(search.value ?? '').trim()
|
||||
const res = await http.get<{ items: PendingServerItem[]; total: number }>(
|
||||
'/servers/pending',
|
||||
q ? { search: q } : undefined,
|
||||
@@ -1361,6 +1409,14 @@ async function refreshStatCards() {
|
||||
function openTerminal(item: ServerApiItem) {
|
||||
router.push({ path: '/terminal', query: { server_id: String(item.id) } })
|
||||
}
|
||||
function openBrowser(item: ServerApiItem) {
|
||||
const url = guessSiteUrlFromDomain(item.domain || '')
|
||||
if (!url) {
|
||||
snackbar('该服务器无有效域名', 'warning')
|
||||
return
|
||||
}
|
||||
globalBrowser.openUrl(url, { title: item.name })
|
||||
}
|
||||
function openFiles(item: ServerApiItem) {
|
||||
router.push({ path: '/files', query: { server_id: String(item.id) } })
|
||||
}
|
||||
@@ -1686,6 +1742,9 @@ async function doDelete() {
|
||||
}
|
||||
|
||||
async function refreshServersPage(silent = false) {
|
||||
await loadServerSearchHistory()
|
||||
search.value = ''
|
||||
searchDraft.value = ''
|
||||
await Promise.all([
|
||||
loadStats(silent),
|
||||
loadCategories(silent),
|
||||
|
||||
@@ -442,6 +442,7 @@ import type {
|
||||
OpsPatrolSyncResponse,
|
||||
SubscriptionParseResponse,
|
||||
IpAllowlistSaveRequest,
|
||||
IpAllowlistSaveResponse,
|
||||
} from '@/types/api'
|
||||
import { NOTIFY_TOGGLE_ITEMS } from '@/types/api'
|
||||
import {
|
||||
@@ -958,10 +959,19 @@ async function saveAllowlist() {
|
||||
subscription_url: subscriptionUrl.value.trim(),
|
||||
enabled: ipAllowlistEnabled.value,
|
||||
}
|
||||
await http.post('/settings/ip-allowlist', payload)
|
||||
snackbar('白名单已保存')
|
||||
const res = await http.post<IpAllowlistSaveResponse>('/settings/ip-allowlist', payload)
|
||||
subscriptionIps.value = res.subscription_ips || []
|
||||
allowlistLastRefresh.value = res.last_refresh || null
|
||||
allowlistTotalCount.value = new Set([
|
||||
...(res.subscription_ips || []),
|
||||
...manual_ips,
|
||||
]).size
|
||||
if (res.refresh_ok === false && res.refresh_error) {
|
||||
snackbar(`白名单已保存,但订阅刷新失败:${res.refresh_error}`, 'warning')
|
||||
} else {
|
||||
snackbar('白名单已保存')
|
||||
}
|
||||
subParseDialog.value = false
|
||||
await loadAllowlist()
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||||
} finally {
|
||||
|
||||
@@ -5,7 +5,7 @@ const routes = [
|
||||
{ path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') },
|
||||
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
|
||||
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
|
||||
{ path: '/rdp', name: 'Rdp', component: () => import('@/pages/RdpPage.vue') },
|
||||
{ path: '/browser', name: 'Browser', component: () => import('@/pages/BrowserPage.vue') },
|
||||
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
|
||||
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
|
||||
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
|
||||
|
||||
@@ -70,11 +70,10 @@ export const useFilesStore = defineStore('files', () => {
|
||||
etag?: string,
|
||||
) {
|
||||
const key = cacheKey(serverId, path)
|
||||
const prev = fileCache.get(key)
|
||||
fileCache.set(key, {
|
||||
items,
|
||||
at: Date.now(),
|
||||
etag: etag ?? prev?.etag,
|
||||
etag: etag ?? undefined,
|
||||
})
|
||||
if (fileCache.size > FILES_CACHE_MAX_ENTRIES) {
|
||||
const oldest = [...fileCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
|
||||
|
||||
@@ -106,6 +106,16 @@ export interface IpAllowlistSaveRequest {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface IpAllowlistSaveResponse {
|
||||
success: boolean
|
||||
manual_count: number
|
||||
subscription_ips?: string[]
|
||||
subscription_count?: number
|
||||
last_refresh?: string | null
|
||||
refresh_ok?: boolean
|
||||
refresh_error?: string | null
|
||||
}
|
||||
|
||||
/** File browse response from POST /api/sync/browse */
|
||||
export interface BrowseResponse {
|
||||
path?: string
|
||||
@@ -161,10 +171,6 @@ export interface ServerApiItem {
|
||||
platform_id: number | null
|
||||
node_id: number | null
|
||||
files_elevation?: FilesElevationMode
|
||||
rdp_enabled?: boolean
|
||||
rdp_port?: number
|
||||
rdp_username?: string
|
||||
rdp_password_set?: boolean
|
||||
is_online: boolean
|
||||
last_heartbeat: string | null
|
||||
agent_version: string | null
|
||||
|
||||
Vendored
-39
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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" "$@"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -78,26 +78,6 @@ class ServerCreate(BaseModel):
|
||||
pattern="^(off|auto_sudo|always_sudo)$",
|
||||
description="File manager sudo policy (stored in extra_attrs)",
|
||||
)
|
||||
rdp_enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Enable native RDP launch (.rdp download); stored in extra_attrs",
|
||||
)
|
||||
rdp_port: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="RDP port (default 3389); stored in extra_attrs",
|
||||
)
|
||||
rdp_username: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="RDP username; stored in extra_attrs",
|
||||
)
|
||||
rdp_password: Optional[str] = Field(
|
||||
default=None,
|
||||
description="RDP password (encrypted in extra_attrs); write-only",
|
||||
)
|
||||
|
||||
@field_validator("target_path", mode="before")
|
||||
@classmethod
|
||||
def _normalize_target_path(cls, v: object) -> object:
|
||||
@@ -130,26 +110,6 @@ class ServerUpdate(BaseModel):
|
||||
pattern="^(off|auto_sudo|always_sudo)$",
|
||||
description="File manager sudo policy (stored in extra_attrs)",
|
||||
)
|
||||
rdp_enabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Enable native RDP launch (.rdp download); stored in extra_attrs",
|
||||
)
|
||||
rdp_port: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="RDP port (default 3389); stored in extra_attrs",
|
||||
)
|
||||
rdp_username: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="RDP username; stored in extra_attrs",
|
||||
)
|
||||
rdp_password: Optional[str] = Field(
|
||||
default=None,
|
||||
description="RDP password (encrypted in extra_attrs); write-only",
|
||||
)
|
||||
|
||||
@field_validator("target_path", mode="before")
|
||||
@classmethod
|
||||
def _normalize_target_path(cls, v: object) -> object:
|
||||
@@ -195,6 +155,19 @@ class BatchCategoryUpdate(BaseModel):
|
||||
category: str = Field("", max_length=100, description="分类名称;留空表示清除分类")
|
||||
|
||||
|
||||
class ServerSearchHistoryUpdate(BaseModel):
|
||||
"""Record or replace servers page search history for current admin."""
|
||||
query: Optional[str] = Field(None, max_length=200, description="记录一次搜索;空字符串清除 last")
|
||||
history: Optional[List[str]] = Field(None, max_length=12, description="整表替换历史(迁移/恢复)")
|
||||
last: Optional[str] = Field(None, max_length=200, description="与 history 联用时指定 last")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_mutation(self) -> "ServerSearchHistoryUpdate":
|
||||
if self.query is None and self.history is None:
|
||||
raise ValueError("query 或 history 至少提供一个")
|
||||
return self
|
||||
|
||||
|
||||
class BatchCategoryResult(BaseModel):
|
||||
updated: int = 0
|
||||
not_found: List[int] = []
|
||||
|
||||
+59
-117
@@ -18,7 +18,7 @@ from server.api.auth_jwt import get_current_admin
|
||||
from server.api.schemas import (
|
||||
ServerCreate, ServerUpdate, ServerCheck, ApiKeyRevealRequest,
|
||||
ServerImportResult, BatchAgentAction, AddByIpRequest, AddByIpsBatchRequest, AddByIpsBatchResult, AddByIpsBatchItemResult,
|
||||
BatchCategoryUpdate, BatchJobStarted, BatchJobStatus,
|
||||
BatchCategoryUpdate, BatchJobStarted, BatchJobStatus, ServerSearchHistoryUpdate,
|
||||
)
|
||||
from server.application.server_batch_common import (
|
||||
install_error_msg as _install_error_msg,
|
||||
@@ -289,6 +289,48 @@ async def server_categories(
|
||||
return {"categories": categories, "platforms": platforms}
|
||||
|
||||
|
||||
@router.get("/search-history", response_model=dict)
|
||||
async def get_server_search_history(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return servers page search history for the current admin."""
|
||||
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
|
||||
from server.utils.server_search_history import (
|
||||
SERVERS_SEARCH_CONTEXT,
|
||||
parse_search_state,
|
||||
)
|
||||
|
||||
repo = AdminUiPreferenceRepositoryImpl(db)
|
||||
raw = await repo.get(admin.id, SERVERS_SEARCH_CONTEXT)
|
||||
return parse_search_state(raw)
|
||||
|
||||
|
||||
@router.put("/search-history", response_model=dict)
|
||||
async def update_server_search_history(
|
||||
payload: ServerSearchHistoryUpdate,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Record a search query or replace full history for the current admin."""
|
||||
from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl
|
||||
from server.utils.server_search_history import (
|
||||
SERVERS_SEARCH_CONTEXT,
|
||||
apply_search_record,
|
||||
apply_search_replace,
|
||||
parse_search_state,
|
||||
)
|
||||
|
||||
repo = AdminUiPreferenceRepositoryImpl(db)
|
||||
state = parse_search_state(await repo.get(admin.id, SERVERS_SEARCH_CONTEXT))
|
||||
if payload.history is not None:
|
||||
state = apply_search_replace(history=payload.history, last=payload.last)
|
||||
else:
|
||||
state = apply_search_record(state, payload.query or "")
|
||||
await repo.upsert(admin.id, SERVERS_SEARCH_CONTEXT, state)
|
||||
return state
|
||||
|
||||
|
||||
@router.get("/logs", response_model=dict)
|
||||
async def get_all_sync_logs(
|
||||
server_id: Optional[int] = Query(None, description="Filter by server ID"),
|
||||
@@ -1138,44 +1180,6 @@ async def get_server(
|
||||
return server_data
|
||||
|
||||
|
||||
@router.get("/{id}/rdp-connect", response_model=dict)
|
||||
async def get_rdp_connect_params(
|
||||
id: int,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""RDP parameters for in-browser Guacamole client (admin JWT; no session audit)."""
|
||||
from server.config import settings
|
||||
from server.utils.rdp_config import get_rdp_config, get_rdp_password_plain
|
||||
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
cfg = get_rdp_config(server)
|
||||
if not cfg.enabled:
|
||||
raise HTTPException(status_code=400, detail="此服务器未启用 RDP")
|
||||
host = (server.domain or "").strip()
|
||||
if not host:
|
||||
raise HTTPException(status_code=400, detail="服务器缺少地址(域名/IP)")
|
||||
|
||||
password = get_rdp_password_plain(server)
|
||||
if not password:
|
||||
raise HTTPException(status_code=400, detail="请先在服务器设置中填写 RDP 密码")
|
||||
|
||||
if not settings.GUACD_HOST:
|
||||
raise HTTPException(status_code=503, detail="服务端未配置 guacd,无法使用浏览器 RDP")
|
||||
|
||||
return {
|
||||
"server_id": server.id,
|
||||
"server_name": server.name,
|
||||
"hostname": host,
|
||||
"port": cfg.port,
|
||||
"username": cfg.username,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{id}/metrics", response_model=dict)
|
||||
async def get_server_metrics(
|
||||
id: int,
|
||||
@@ -1242,15 +1246,11 @@ async def setup_files_sudo(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
service: ServerService = Depends(get_server_service),
|
||||
):
|
||||
"""Auto-configure passwordless sudoers for file manager on non-root servers.
|
||||
|
||||
Uses the stored SSH password to run `sudo -S` and write
|
||||
/etc/sudoers.d/nexus-files on the target server.
|
||||
Requires the SSH user to have sudo access with password.
|
||||
"""
|
||||
import textwrap
|
||||
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
"""Auto-configure passwordless sudoers for file manager + rsync push on non-root servers."""
|
||||
from server.application.services.files_sudoers_service import (
|
||||
NEXUS_FILES_SUDOERS_PATH,
|
||||
install_nexus_files_sudoers,
|
||||
)
|
||||
|
||||
server = await service.get_server(id)
|
||||
if not server:
|
||||
@@ -1260,70 +1260,23 @@ async def setup_files_sudo(
|
||||
if ssh_user == "root":
|
||||
return {"ok": True, "message": "root 用户无需配置 sudoers,已拥有完整权限"}
|
||||
|
||||
if not server.password:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="该服务器未存储 SSH 密码,无法自动配置。请手动在目标机配置 sudoers。",
|
||||
)
|
||||
|
||||
plain_password = decrypt_value(server.password)
|
||||
sudoers_content = textwrap.dedent(f"""\
|
||||
# nexus-files: auto-configured by Nexus file manager
|
||||
{ssh_user} ALL=(root) NOPASSWD: \\
|
||||
/bin/ls, /usr/bin/ls, \\
|
||||
/bin/cat, /usr/bin/cat, \\
|
||||
/usr/bin/tee, /bin/tee, \\
|
||||
/bin/mkdir, /usr/bin/mkdir, \\
|
||||
/bin/cp, /usr/bin/cp, \\
|
||||
/bin/mv, /usr/bin/mv, \\
|
||||
/bin/rm, /usr/bin/rm, \\
|
||||
/bin/chmod, /usr/bin/chmod, \\
|
||||
/bin/chown, /usr/bin/chown, \\
|
||||
/bin/bash, /usr/bin/bash
|
||||
""")
|
||||
|
||||
sudoers_path = "/etc/sudoers.d/nexus-files"
|
||||
# Use heredoc via sudo -S to avoid exposing password in command arguments
|
||||
write_cmd = (
|
||||
f"printf %s {shlex.quote(sudoers_content)}"
|
||||
f" | sudo -S tee {shlex.quote(sudoers_path)} > /dev/null"
|
||||
f" && sudo chmod 440 {shlex.quote(sudoers_path)}"
|
||||
f" && sudo visudo -cf {shlex.quote(sudoers_path)}"
|
||||
)
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = await ssh_pool.acquire(server)
|
||||
# Feed password via stdin for sudo -S (avoids exposing password in process args)
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(write_cmd, input=plain_password + "\n", timeout=15),
|
||||
timeout=20,
|
||||
)
|
||||
exit_code = result.exit_status if result.exit_status is not None else -1
|
||||
stderr_out = (result.stderr or "").strip()
|
||||
stdout_out = (result.stdout or "").strip()
|
||||
if exit_code != 0:
|
||||
detail = stderr_out or stdout_out or f"命令退出码 {exit_code}"
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"sudoers 写入失败:{detail[:300]}",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
outcome = await install_nexus_files_sudoers(server)
|
||||
if outcome.get("action") == "already_ok":
|
||||
return {
|
||||
"ok": True,
|
||||
"message": f"sudo -n rsync 已可用,无需重复配置({NEXUS_FILES_SUDOERS_PATH})",
|
||||
}
|
||||
if not outcome.get("ok"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"SSH 执行失败:{exc}",
|
||||
) from exc
|
||||
finally:
|
||||
if conn is not None:
|
||||
await ssh_pool.release(server.id)
|
||||
detail=outcome.get("detail") or f"sudoers 配置失败:{outcome.get('action')}",
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": (
|
||||
f"sudoers 已配置:{sudoers_path}。"
|
||||
"现在文件管理器可使用 sudo -n 自动提权浏览/操作文件。"
|
||||
f"sudoers 已配置:{NEXUS_FILES_SUDOERS_PATH}。"
|
||||
"文件管理与文件推送可使用 sudo -n 自动提权。"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1342,11 +1295,9 @@ async def create_server(
|
||||
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
|
||||
|
||||
from server.utils.files_elevation import apply_files_elevation_payload
|
||||
from server.utils.rdp_config import apply_rdp_payload
|
||||
|
||||
data = payload.model_dump(exclude_none=True)
|
||||
apply_files_elevation_payload(data)
|
||||
apply_rdp_payload(data)
|
||||
|
||||
# If preset_id is provided, resolve the preset password (server-side, no re-auth needed)
|
||||
if data.get("preset_id") and data.get("auth_method") == "password" and not data.get("password"):
|
||||
@@ -1417,12 +1368,10 @@ async def update_server(
|
||||
raise HTTPException(status_code=404, detail="Server not found")
|
||||
|
||||
from server.utils.files_elevation import apply_files_elevation_payload
|
||||
from server.utils.rdp_config import apply_rdp_payload
|
||||
|
||||
# If preset_id is provided (and not None), resolve the preset password
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
apply_files_elevation_payload(update_data)
|
||||
apply_rdp_payload(update_data, existing_extra=server.extra_attrs)
|
||||
if "preset_id" in update_data and update_data["preset_id"] is not None and not update_data.get("password"):
|
||||
preset_repo = PasswordPresetRepositoryImpl(db)
|
||||
preset = await preset_repo.get_by_id(update_data["preset_id"])
|
||||
@@ -2059,9 +2008,6 @@ def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict
|
||||
def _server_to_dict(server: Server) -> dict:
|
||||
"""Convert Server model to API response dict (hide sensitive fields)"""
|
||||
from server.utils.files_elevation import get_files_elevation
|
||||
from server.utils.rdp_config import get_rdp_config
|
||||
|
||||
rdp = get_rdp_config(server)
|
||||
|
||||
return {
|
||||
"id": server.id,
|
||||
@@ -2087,10 +2033,6 @@ def _server_to_dict(server: Server) -> dict:
|
||||
"protocols": server.protocols,
|
||||
"extra_attrs": server.extra_attrs,
|
||||
"files_elevation": get_files_elevation(server).value,
|
||||
"rdp_enabled": rdp.enabled,
|
||||
"rdp_port": rdp.port,
|
||||
"rdp_username": rdp.username,
|
||||
"rdp_password_set": rdp.password_set,
|
||||
"connectivity": server.connectivity,
|
||||
"ssh_key_configured": server.ssh_key_configured,
|
||||
"is_online": server.is_online,
|
||||
|
||||
+64
-35
@@ -1321,6 +1321,28 @@ class IpAllowlistSaveRequest(BaseModel):
|
||||
enabled: Optional[bool] = None # None = don't change
|
||||
|
||||
|
||||
def _allowlist_save_response(*, manual_count: int, refresh, has_subscription: bool) -> dict:
|
||||
"""Build POST /ip-allowlist response with subscription refresh outcome."""
|
||||
if not has_subscription:
|
||||
return {
|
||||
"success": True,
|
||||
"manual_count": manual_count,
|
||||
"subscription_ips": [],
|
||||
"subscription_count": 0,
|
||||
"last_refresh": None,
|
||||
"refresh_ok": True,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"manual_count": manual_count,
|
||||
"subscription_ips": list(refresh.subscription_ips),
|
||||
"subscription_count": refresh.subscription_count,
|
||||
"last_refresh": refresh.last_refresh or None,
|
||||
"refresh_ok": refresh.ok,
|
||||
"refresh_error": refresh.error if not refresh.ok else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ip-allowlist", response_model=dict)
|
||||
async def set_ip_allowlist(
|
||||
payload: IpAllowlistSaveRequest,
|
||||
@@ -1330,37 +1352,44 @@ async def set_ip_allowlist(
|
||||
):
|
||||
"""Save manual IPs, subscription URL, and/or enabled toggle."""
|
||||
from server.config import settings as _settings
|
||||
from server.background.ip_allowlist_refresh import RefreshResult, do_refresh
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
|
||||
if payload.manual_ips:
|
||||
_validate_ip_list(payload.manual_ips)
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
|
||||
# Toggle enabled/disabled
|
||||
if payload.enabled is not None:
|
||||
val = "true" if payload.enabled else "false"
|
||||
await repo.set("login_allowlist_enabled", val)
|
||||
_settings.LOGIN_ALLOWLIST_ENABLED = val
|
||||
|
||||
# Manual IPs
|
||||
manual_cleaned = [ip.strip() for ip in payload.manual_ips if ip.strip()]
|
||||
manual_val = ",".join(manual_cleaned)
|
||||
await repo.set("login_manual_ips", manual_val)
|
||||
_settings.LOGIN_MANUAL_IPS = manual_val
|
||||
changes: dict[str, str] = {"login_manual_ips": manual_val}
|
||||
|
||||
if payload.enabled is not None:
|
||||
val = "true" if payload.enabled else "false"
|
||||
await repo.set("login_allowlist_enabled", val)
|
||||
changes["login_allowlist_enabled"] = val
|
||||
|
||||
# Subscription URL
|
||||
sub_url_changed = False
|
||||
if payload.subscription_url is not None:
|
||||
sub_url = payload.subscription_url.strip()
|
||||
await repo.set("login_subscription_url", sub_url)
|
||||
_settings.LOGIN_SUBSCRIPTION_URL = sub_url
|
||||
sub_url_changed = bool(sub_url)
|
||||
changes["login_subscription_url"] = sub_url
|
||||
|
||||
# If no subscription URL, rebuild combined from manual only
|
||||
if not _settings.LOGIN_SUBSCRIPTION_URL:
|
||||
if payload.subscription_url is not None:
|
||||
sub_url_now = payload.subscription_url.strip()
|
||||
else:
|
||||
sub_url_now = (_settings.LOGIN_SUBSCRIPTION_URL or "").strip()
|
||||
|
||||
refresh = RefreshResult(ok=True)
|
||||
if not sub_url_now:
|
||||
await repo.set("login_allowed_ips", manual_val)
|
||||
await repo.set("login_subscription_ips", "")
|
||||
_settings.LOGIN_ALLOWED_IPS = manual_val
|
||||
_settings.LOGIN_SUBSCRIPTION_IPS = ""
|
||||
changes["login_allowed_ips"] = manual_val
|
||||
changes["login_subscription_ips"] = ""
|
||||
|
||||
await publish_setting_changes(changes)
|
||||
|
||||
if sub_url_now:
|
||||
refresh = await do_refresh()
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
from server.domain.models import AuditLog
|
||||
@@ -1373,13 +1402,11 @@ async def set_ip_allowlist(
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
# Trigger refresh if subscription URL is present
|
||||
if sub_url_changed or _settings.LOGIN_SUBSCRIPTION_URL:
|
||||
from server.background.ip_allowlist_refresh import _do_refresh
|
||||
import asyncio
|
||||
asyncio.create_task(_do_refresh())
|
||||
|
||||
return {"success": True, "manual_count": len(manual_cleaned)}
|
||||
return _allowlist_save_response(
|
||||
manual_count=len(manual_cleaned),
|
||||
refresh=refresh,
|
||||
has_subscription=bool(sub_url_now),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ip-allowlist/toggle", response_model=dict)
|
||||
@@ -1390,12 +1417,12 @@ async def toggle_ip_allowlist(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Quick toggle for allowlist enforcement (no IP list changes)."""
|
||||
from server.config import settings as _settings
|
||||
|
||||
val = "true" if enabled else "false"
|
||||
repo = SettingRepositoryImpl(db)
|
||||
await repo.set("login_allowlist_enabled", val)
|
||||
_settings.LOGIN_ALLOWLIST_ENABLED = val
|
||||
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
await publish_setting_changes({"login_allowlist_enabled": val})
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
from server.domain.models import AuditLog
|
||||
@@ -1426,7 +1453,11 @@ async def add_manual_ips(
|
||||
val = ",".join(merged)
|
||||
repo = SettingRepositoryImpl(db)
|
||||
await repo.set("login_manual_ips", val)
|
||||
_settings.LOGIN_MANUAL_IPS = val
|
||||
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
from server.background.ip_allowlist_refresh import do_refresh
|
||||
await publish_setting_changes({"login_manual_ips": val})
|
||||
await do_refresh()
|
||||
|
||||
# S-07: Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
@@ -1438,9 +1469,6 @@ async def add_manual_ips(
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
from server.background.ip_allowlist_refresh import _do_refresh
|
||||
import asyncio
|
||||
asyncio.create_task(_do_refresh())
|
||||
return {"success": True, "manual_ips": merged}
|
||||
|
||||
|
||||
@@ -1459,7 +1487,11 @@ async def remove_allowlist_ip(
|
||||
val = ",".join(remaining)
|
||||
repo = SettingRepositoryImpl(db)
|
||||
await repo.set("login_manual_ips", val)
|
||||
_settings.LOGIN_MANUAL_IPS = val
|
||||
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
from server.background.ip_allowlist_refresh import do_refresh
|
||||
await publish_setting_changes({"login_manual_ips": val})
|
||||
await do_refresh()
|
||||
|
||||
# S-07: Audit log
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
@@ -1471,9 +1503,6 @@ async def remove_allowlist_ip(
|
||||
ip_address=request.client.host if request.client else "",
|
||||
))
|
||||
|
||||
from server.background.ip_allowlist_refresh import _do_refresh
|
||||
import asyncio
|
||||
asyncio.create_task(_do_refresh())
|
||||
return {"success": True, "removed": ip, "remaining": remaining}
|
||||
|
||||
|
||||
|
||||
+43
-20
@@ -626,6 +626,7 @@ async def diagnose_push(
|
||||
"""
|
||||
import shlex
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.utils.sync_error_message import translate_sync_error_message
|
||||
|
||||
@@ -643,6 +644,7 @@ async def diagnose_push(
|
||||
"path_exists": None,
|
||||
"path_perms": None,
|
||||
"path_writable": None,
|
||||
"path_elevated": False,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
@@ -699,16 +701,15 @@ async def diagnose_push(
|
||||
# 4. Write test
|
||||
if result["path_exists"]:
|
||||
try:
|
||||
test_file = remote_join(dest, ".nexus_diag_test_$$")
|
||||
r = await exec_ssh_command(
|
||||
server,
|
||||
f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}",
|
||||
timeout=10,
|
||||
)
|
||||
test_file = remote_join(dest, ".nexus_diag_test")
|
||||
write_cmd = f"touch {shlex.quote(test_file)} && rm -f {shlex.quote(test_file)}"
|
||||
r = await exec_ssh_command_with_fallback(server, write_cmd, timeout=10)
|
||||
result["path_writable"] = r["exit_code"] == 0
|
||||
if r["exit_code"] != 0:
|
||||
stderr_zh = translate_sync_error_message((r.get("stderr") or "")[:200]) or ""
|
||||
result["errors"].append(f"目标路径不可写: {stderr_zh}")
|
||||
elif r.get("elevated"):
|
||||
result["path_elevated"] = True
|
||||
except Exception as e:
|
||||
result["errors"].append(f"写入测试失败: {e}")
|
||||
result["path_writable"] = False
|
||||
@@ -864,7 +865,7 @@ async def file_diff(
|
||||
|
||||
# ── File Upload via SFTP ──
|
||||
|
||||
MAX_UPLOAD_FILE_SIZE = 104_857_600 # 100 MB
|
||||
MAX_UPLOAD_FILE_SIZE = 524_288_000 # 500 MB
|
||||
|
||||
|
||||
def _collect_multipart_upload_files(form) -> list:
|
||||
@@ -927,11 +928,22 @@ async def _sftp_upload_one(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"SSH 连接失败: {e}") from e
|
||||
|
||||
from server.utils.files_upload_permissions import apply_upload_file_permissions
|
||||
|
||||
permission_fixup = await apply_upload_file_permissions(server, full_remote_path)
|
||||
|
||||
audit_detail = f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}"
|
||||
if permission_fixup.get("applied"):
|
||||
pf_owner = permission_fixup.get("owner") or ""
|
||||
pf_group = permission_fixup.get("group") or pf_owner
|
||||
pf_mode = permission_fixup.get("mode") or ""
|
||||
audit_detail += f" [chown {pf_owner}:{pf_group} chmod {pf_mode}]"
|
||||
|
||||
await _audit_sync(
|
||||
"file_upload",
|
||||
"server",
|
||||
server_id,
|
||||
f"上传 {safe_filename} ({len(content)} bytes) → {server.name}:{full_remote_path}",
|
||||
audit_detail,
|
||||
admin_username,
|
||||
request,
|
||||
)
|
||||
@@ -940,6 +952,7 @@ async def _sftp_upload_one(
|
||||
"remote_path": full_remote_path,
|
||||
"filename": safe_filename,
|
||||
"size": len(content),
|
||||
"permission_fixup": permission_fixup,
|
||||
}
|
||||
|
||||
|
||||
@@ -1009,6 +1022,7 @@ async def upload_file(
|
||||
"remote_path": one["remote_path"],
|
||||
"filename": one["filename"],
|
||||
"size": one["size"],
|
||||
"permission_fixup": one.get("permission_fixup"),
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1453,8 +1467,8 @@ async def download_file(
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
||||
|
||||
# H-15: Download file size limit (100MB)
|
||||
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
|
||||
# H-15: Download file size limit (500MB)
|
||||
MAX_DOWNLOAD_SIZE = 524_288_000
|
||||
if hasattr(stat, "size") and stat.size and stat.size > MAX_DOWNLOAD_SIZE:
|
||||
await ssh_pool.release(server.id)
|
||||
raise HTTPException(status_code=413, detail=f"文件大小 {stat.size} 字节超过下载限制 100MB")
|
||||
@@ -1727,21 +1741,30 @@ async def chmod_file(
|
||||
|
||||
chmod_prefix = "chmod -R " if recursive else "chmod "
|
||||
chown_prefix = "chown -R " if recursive else "chown "
|
||||
cmd_parts = [f"{chmod_prefix}{shlex.quote(payload.mode)} {path_q}"]
|
||||
if payload.owner:
|
||||
group = (payload.group or payload.owner).strip()
|
||||
owner_spec = shlex.quote(f"{payload.owner}:{group}")
|
||||
cmd_parts.append(f"{chown_prefix}{owner_spec} {path_q}")
|
||||
|
||||
timeout = RECURSIVE_CHMOD_TIMEOUT_SEC if recursive else SINGLE_CHMOD_TIMEOUT_SEC
|
||||
result = await exec_ssh_command_with_fallback(server, " && ".join(cmd_parts), timeout=timeout)
|
||||
if result["exit_code"] != 0:
|
||||
err = ((result.get("stderr") or "") + (result.get("stdout") or ""))[:300]
|
||||
chmod_cmd = f"{chmod_prefix}{payload.mode} {path_q}"
|
||||
chmod_result = await exec_ssh_command_with_fallback(server, chmod_cmd, timeout=timeout)
|
||||
if chmod_result["exit_code"] != 0:
|
||||
err = ((chmod_result.get("stderr") or "") + (chmod_result.get("stdout") or ""))[:300]
|
||||
raise HTTPException(
|
||||
status_code=403 if "permission denied" in err.lower() else 502,
|
||||
detail=err or "chmod 失败",
|
||||
)
|
||||
|
||||
elevated = bool(chmod_result.get("elevated"))
|
||||
if payload.owner:
|
||||
group = (payload.group or payload.owner).strip()
|
||||
owner_spec = shlex.quote(f"{payload.owner}:{group}")
|
||||
chown_cmd = f"{chown_prefix}{owner_spec} {path_q}"
|
||||
chown_result = await exec_ssh_command_with_fallback(server, chown_cmd, timeout=timeout)
|
||||
if chown_result["exit_code"] != 0:
|
||||
err = ((chown_result.get("stderr") or "") + (chown_result.get("stdout") or ""))[:300]
|
||||
raise HTTPException(
|
||||
status_code=403 if "permission denied" in err.lower() else 502,
|
||||
detail=f"权限已修改为 {payload.mode},但 chown 失败: {err or '未知错误'}",
|
||||
)
|
||||
elevated = elevated or bool(chown_result.get("elevated"))
|
||||
|
||||
audit_bits = [f"chmod {'-R ' if recursive else ''}{payload.mode}"]
|
||||
if payload.owner:
|
||||
audit_bits.append(
|
||||
@@ -1767,7 +1790,7 @@ async def chmod_file(
|
||||
"owner": payload.owner,
|
||||
"group": payload.group,
|
||||
"recursive": recursive,
|
||||
"elevated": bool(result.get("elevated")),
|
||||
"elevated": elevated,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,10 +28,22 @@ def browse_cache_key(server_id: int, path: str) -> str:
|
||||
|
||||
|
||||
def compute_browse_etag(path: str, items: list[dict[str, Any]]) -> str:
|
||||
"""Weak ETag from path + sorted entry fingerprints (name|mtime|size)."""
|
||||
"""Weak ETag from path + sorted entry fingerprints (name|mtime|size|perms|owner|group)."""
|
||||
lines = [path]
|
||||
for item in sorted(items, key=lambda x: x.get("name") or ""):
|
||||
lines.append(f"{item.get('name', '')}|{item.get('modified', '')}|{item.get('size', 0)}")
|
||||
lines.append(
|
||||
"|".join(
|
||||
[
|
||||
str(item.get("name", "")),
|
||||
str(item.get("modified", "")),
|
||||
str(item.get("size", 0)),
|
||||
str(item.get("permissions", "")),
|
||||
str(item.get("owner", "")),
|
||||
str(item.get("group", "")),
|
||||
str(item.get("mode_octal", "")),
|
||||
]
|
||||
)
|
||||
)
|
||||
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
||||
return f'W/"{digest}"'
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Shared Nexus files sudoers template and remote install helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shlex
|
||||
import textwrap
|
||||
|
||||
from server.domain.models import Server
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command, ssh_pool
|
||||
from server.infrastructure.database.crypto import decrypt_value
|
||||
from server.infrastructure.ssh.remote_shell import exec_ssh_command_with_fallback
|
||||
|
||||
NEXUS_FILES_SUDOERS_PATH = "/etc/sudoers.d/nexus-files"
|
||||
|
||||
|
||||
def build_nexus_files_sudoers(ssh_user: str) -> str:
|
||||
"""Return sudoers.d fragment for file manager + rsync push elevation."""
|
||||
user = (ssh_user or "deploy").strip() or "deploy"
|
||||
return textwrap.dedent(f"""\
|
||||
# nexus-files: configured by Nexus (file manager + rsync push)
|
||||
{user} ALL=(root) NOPASSWD: \\
|
||||
/bin/ls, /usr/bin/ls, \\
|
||||
/bin/cat, /usr/bin/cat, \\
|
||||
/usr/bin/tee, /bin/tee, \\
|
||||
/bin/mkdir, /usr/bin/mkdir, \\
|
||||
/bin/cp, /usr/bin/cp, \\
|
||||
/bin/mv, /usr/bin/mv, \\
|
||||
/bin/rm, /usr/bin/rm, \\
|
||||
/bin/chmod, /usr/bin/chmod, \\
|
||||
/bin/chown, /usr/bin/chown, \\
|
||||
/bin/bash, /usr/bin/bash, \\
|
||||
/usr/bin/rsync, /bin/rsync
|
||||
""")
|
||||
|
||||
|
||||
async def probe_rsync_sudo(server: Server, *, timeout: int = 15) -> bool:
|
||||
"""True when ``sudo -n rsync`` works on the target."""
|
||||
ssh_user = (server.username or "root").strip() or "root"
|
||||
if ssh_user == "root":
|
||||
result = await exec_ssh_command(server, "rsync --version", timeout=timeout)
|
||||
return result.get("exit_code") == 0
|
||||
result = await exec_ssh_command(server, "sudo -n rsync --version", timeout=timeout)
|
||||
return result.get("exit_code") == 0
|
||||
|
||||
|
||||
async def _write_sudoers_with_password(
|
||||
server: Server,
|
||||
content: str,
|
||||
sudoers_path: str,
|
||||
plain_password: str,
|
||||
) -> dict:
|
||||
write_cmd = (
|
||||
f"printf %s {shlex.quote(content)}"
|
||||
f" | sudo -S tee {shlex.quote(sudoers_path)} > /dev/null"
|
||||
f" && sudo chmod 440 {shlex.quote(sudoers_path)}"
|
||||
f" && sudo visudo -cf {shlex.quote(sudoers_path)}"
|
||||
)
|
||||
conn = None
|
||||
try:
|
||||
conn = await ssh_pool.acquire(server)
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(write_cmd, input=plain_password + "\n", timeout=20),
|
||||
timeout=25,
|
||||
)
|
||||
exit_code = result.exit_status if result.exit_status is not None else -1
|
||||
stderr_out = (result.stderr or "").strip()
|
||||
stdout_out = (result.stdout or "").strip()
|
||||
if exit_code != 0:
|
||||
detail = stderr_out or stdout_out or f"exit {exit_code}"
|
||||
return {"ok": False, "action": "password_sudo_failed", "detail": detail[:500]}
|
||||
return {"ok": True, "action": "patched_password_sudo"}
|
||||
finally:
|
||||
if conn is not None:
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
|
||||
async def install_nexus_files_sudoers(server: Server, *, dry_run: bool = False) -> dict:
|
||||
"""Ensure ``/etc/sudoers.d/nexus-files`` includes rsync (and file ops whitelist).
|
||||
|
||||
Returns ``{ok, action, server_id, server_name, ssh_user, detail?}``.
|
||||
"""
|
||||
ssh_user = (server.username or "root").strip() or "root"
|
||||
base = {
|
||||
"server_id": server.id,
|
||||
"server_name": server.name,
|
||||
"ssh_user": ssh_user,
|
||||
}
|
||||
|
||||
if ssh_user == "root":
|
||||
return {**base, "ok": True, "action": "skip_root"}
|
||||
|
||||
if await probe_rsync_sudo(server):
|
||||
return {**base, "ok": True, "action": "already_ok"}
|
||||
|
||||
if dry_run:
|
||||
return {**base, "ok": True, "action": "would_patch"}
|
||||
|
||||
content = build_nexus_files_sudoers(ssh_user)
|
||||
sudoers_path = NEXUS_FILES_SUDOERS_PATH
|
||||
|
||||
if server.password:
|
||||
plain = decrypt_value(server.password)
|
||||
outcome = await _write_sudoers_with_password(server, content, sudoers_path, plain)
|
||||
if not outcome["ok"]:
|
||||
return {**base, **outcome}
|
||||
if await probe_rsync_sudo(server):
|
||||
return {**base, **outcome}
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"action": "written_but_rsync_probe_failed",
|
||||
"detail": "sudoers 已写入但 sudo -n rsync 仍失败",
|
||||
}
|
||||
|
||||
write_inner = (
|
||||
f"printf %s {shlex.quote(content)}"
|
||||
f" | tee {shlex.quote(sudoers_path)} > /dev/null"
|
||||
f" && chmod 440 {shlex.quote(sudoers_path)}"
|
||||
f" && visudo -cf {shlex.quote(sudoers_path)}"
|
||||
)
|
||||
result = await exec_ssh_command_with_fallback(server, write_inner, timeout=30)
|
||||
if result.get("exit_code") != 0:
|
||||
detail = (result.get("stderr") or result.get("stdout") or "")[:500]
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"action": "nopasswd_write_failed",
|
||||
"detail": detail or "无法写入 sudoers(需存储 SSH 密码或已配置 bash 免密 sudo)",
|
||||
}
|
||||
|
||||
if await probe_rsync_sudo(server):
|
||||
return {**base, "ok": True, "action": "patched_nopasswd"}
|
||||
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"action": "written_but_rsync_probe_failed",
|
||||
"detail": "sudoers 已写入但 sudo -n rsync 仍失败,请检查 visudo 与白名单路径",
|
||||
}
|
||||
@@ -43,9 +43,10 @@ _RSYNC_CHOWN_RE = re.compile(r"^[a-zA-Z0-9_.-]+(:[a-zA-Z0-9_.-]+)?$")
|
||||
_RSYNC_CHMOD_RE = re.compile(r"^[DF0-9a-zA-Z=+,:-]+$")
|
||||
|
||||
|
||||
def rsync_push_permission_summary() -> str | None:
|
||||
"""Human/log snapshot of rsync --chown/--chmod applied on real pushes."""
|
||||
def rsync_push_permission_summary(server: Server | None = None) -> str | None:
|
||||
"""Human/log snapshot of rsync --chown/--chmod and elevation applied on real pushes."""
|
||||
from server.config import settings
|
||||
from server.utils.rsync_elevation import rsync_elevation_log_hint
|
||||
|
||||
parts: list[str] = []
|
||||
chown = (settings.RSYNC_PUSH_CHOWN or "").strip()
|
||||
@@ -54,6 +55,10 @@ def rsync_push_permission_summary() -> str | None:
|
||||
parts.append(f"chown={chown}")
|
||||
if chmod and _RSYNC_CHMOD_RE.fullmatch(chmod):
|
||||
parts.append(f"chmod={chmod}")
|
||||
if server is not None:
|
||||
elevation_hint = rsync_elevation_log_hint(server)
|
||||
if elevation_hint:
|
||||
parts.append(elevation_hint)
|
||||
return " ".join(parts) if parts else None
|
||||
|
||||
|
||||
@@ -185,7 +190,7 @@ class SyncEngineV2:
|
||||
operator=operator,
|
||||
status="running",
|
||||
sync_mode=sync_mode,
|
||||
push_permission=rsync_push_permission_summary(),
|
||||
push_permission=rsync_push_permission_summary(server),
|
||||
started_at=datetime.now(timezone.utc),
|
||||
)
|
||||
async with _db_lock:
|
||||
@@ -452,9 +457,36 @@ async def _rsync_push(
|
||||
) -> dict:
|
||||
"""Execute rsync on Nexus host, pushing to target server via SSH.
|
||||
|
||||
Handles both password auth (sshpass) and key auth (temp key file).
|
||||
Returns {exit_code, stdout, stderr}.
|
||||
Non-root servers with ``files_elevation`` not ``off`` use
|
||||
``--rsync-path=sudo -n rsync`` (default ``auto_sudo``, same as file manager).
|
||||
"""
|
||||
from server.utils.rsync_elevation import rsync_receiver_path_for_push
|
||||
|
||||
sudo_path = rsync_receiver_path_for_push(server)
|
||||
result = await _execute_rsync_push(
|
||||
server,
|
||||
source_path,
|
||||
target_path,
|
||||
sync_mode=sync_mode,
|
||||
dry_run=dry_run,
|
||||
verbose=verbose,
|
||||
rsync_receiver_path=sudo_path,
|
||||
)
|
||||
result["elevated"] = bool(sudo_path and result.get("exit_code") == 0)
|
||||
return result
|
||||
|
||||
|
||||
async def _execute_rsync_push(
|
||||
server: Server,
|
||||
source_path: str,
|
||||
target_path: str,
|
||||
*,
|
||||
sync_mode: str = "incremental",
|
||||
dry_run: bool = False,
|
||||
verbose: bool = False,
|
||||
rsync_receiver_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Run one rsync attempt on the Nexus host. Returns {exit_code, stdout, stderr}."""
|
||||
import shlex
|
||||
|
||||
ssh_user = server.username or "root"
|
||||
@@ -463,7 +495,6 @@ async def _rsync_push(
|
||||
remote_dest = normalize_remote_abs_path(to_posix(target_path))
|
||||
rsync_remote = f"{ssh_user}@{ssh_host}:{remote_dest}"
|
||||
|
||||
# Build rsync args
|
||||
args = ["rsync", "-az"]
|
||||
if sync_mode == "full":
|
||||
args.append("--delete")
|
||||
@@ -481,18 +512,17 @@ async def _rsync_push(
|
||||
if not dry_run:
|
||||
args.extend(rsync_push_permission_args())
|
||||
|
||||
# SSH options: port + no strict host key checking (matches asyncssh pool behavior)
|
||||
if rsync_receiver_path:
|
||||
args.extend(["--rsync-path", rsync_receiver_path])
|
||||
|
||||
ssh_opts = f"ssh -o StrictHostKeyChecking=no -p {ssh_port}"
|
||||
|
||||
# Auth: prepare temp key file or sshpass
|
||||
key_file = None
|
||||
env_override = None
|
||||
|
||||
try:
|
||||
if server.auth_method == "password" and server.password:
|
||||
password = decrypt_value(server.password)
|
||||
# sshpass -p requires the password as an argument (visible in /proc on Linux,
|
||||
# but acceptable for internal ops tool; sshpass -e from env is slightly safer)
|
||||
env_override = {"SSHPASS": password}
|
||||
args = ["sshpass", "-e"] + args
|
||||
ssh_opts += " -o PubkeyAuthentication=no"
|
||||
@@ -512,11 +542,9 @@ async def _rsync_push(
|
||||
return {"exit_code": -1, "stdout": "", "stderr": f"服务器 {server.name} 无有效 SSH 凭据"}
|
||||
|
||||
args.extend(["-e", ssh_opts])
|
||||
# source_path: Nexus 主机本地目录(生产环境为 Linux);保持原样供 rsync 读取
|
||||
args.append(source_path.rstrip("/") + "/")
|
||||
args.append(rsync_remote.rstrip("/") + "/")
|
||||
|
||||
# Execute rsync on Nexus host
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
|
||||
@@ -8,8 +8,11 @@ Data model:
|
||||
login_allowlist_enabled — "true" / "false" master switch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger("nexus.ip_allowlist_refresh")
|
||||
|
||||
@@ -18,18 +21,32 @@ _last_refresh_at: str = ""
|
||||
_last_subscription_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RefreshResult:
|
||||
ok: bool
|
||||
subscription_ips: tuple[str, ...] = ()
|
||||
last_refresh: str = ""
|
||||
subscription_count: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
async def ip_allowlist_refresh_loop():
|
||||
"""Every 2 hours, re-fetch subscription IPs and rebuild combined allowlist."""
|
||||
logger.info("IP allowlist refresh loop started (interval=%sh)", REFRESH_INTERVAL // 3600)
|
||||
# Initial refresh shortly after startup
|
||||
await asyncio.sleep(5)
|
||||
await _do_refresh()
|
||||
await do_refresh()
|
||||
while True:
|
||||
await asyncio.sleep(REFRESH_INTERVAL)
|
||||
await _do_refresh()
|
||||
await do_refresh()
|
||||
|
||||
|
||||
async def _do_refresh():
|
||||
async def do_refresh() -> RefreshResult:
|
||||
"""Fetch subscription, replace subscription IPs, rebuild combined list."""
|
||||
return await _do_refresh()
|
||||
|
||||
|
||||
async def _do_refresh() -> RefreshResult:
|
||||
"""Fetch subscription, replace subscription IPs, rebuild combined list."""
|
||||
global _last_refresh_at, _last_subscription_count
|
||||
from server.config import settings
|
||||
@@ -37,7 +54,7 @@ async def _do_refresh():
|
||||
|
||||
sub_url = (settings.LOGIN_SUBSCRIPTION_URL or "").strip()
|
||||
if not sub_url:
|
||||
return
|
||||
return RefreshResult(ok=True)
|
||||
|
||||
logger.info("Refreshing subscription IPs from: %s", sub_url[:80])
|
||||
try:
|
||||
@@ -46,11 +63,11 @@ async def _do_refresh():
|
||||
resp = await client.get(sub_url, follow_redirects=True)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Subscription returned HTTP %s", resp.status_code)
|
||||
return
|
||||
return RefreshResult(ok=False, error=f"订阅链接返回 HTTP {resp.status_code}")
|
||||
new_sub_ips = parse_subscription_content(resp.text)
|
||||
except Exception as e:
|
||||
logger.error("Subscription fetch failed: %s", e)
|
||||
return
|
||||
return RefreshResult(ok=False, error=f"拉取订阅失败: {e}")
|
||||
|
||||
# Manual IPs (always preserved)
|
||||
manual_raw = (settings.LOGIN_MANUAL_IPS or "").strip()
|
||||
@@ -70,16 +87,21 @@ async def _do_refresh():
|
||||
try:
|
||||
from server.infrastructure.database.session import AsyncSessionLocal
|
||||
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
|
||||
from server.infrastructure.settings_broadcast import publish_setting_changes
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
repo = SettingRepositoryImpl(session)
|
||||
# Subscription IPs — replaced entirely
|
||||
await repo.set("login_subscription_ips", sub_val)
|
||||
# Combined — for auth check
|
||||
await repo.set("login_allowed_ips", combined_val)
|
||||
|
||||
settings.LOGIN_SUBSCRIPTION_IPS = sub_val
|
||||
settings.LOGIN_ALLOWED_IPS = combined_val
|
||||
|
||||
await publish_setting_changes({
|
||||
"login_subscription_ips": sub_val,
|
||||
"login_allowed_ips": combined_val,
|
||||
})
|
||||
|
||||
from server.utils.display_time import format_beijing
|
||||
|
||||
_last_refresh_at = format_beijing(with_label=False)
|
||||
@@ -88,8 +110,15 @@ async def _do_refresh():
|
||||
"Subscription IPs refreshed: %d → combined %d (+ %d manual)",
|
||||
len(new_sub_ips), len(combined), len(manual_ips),
|
||||
)
|
||||
return RefreshResult(
|
||||
ok=True,
|
||||
subscription_ips=tuple(new_sub_ips),
|
||||
last_refresh=_last_refresh_at,
|
||||
subscription_count=len(new_sub_ips),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to save subscription IPs: %s", e)
|
||||
return RefreshResult(ok=False, error=f"保存订阅 IP 失败: {e}")
|
||||
|
||||
|
||||
def get_last_refresh_time() -> str:
|
||||
|
||||
@@ -57,10 +57,6 @@ class Settings(BaseSettings):
|
||||
# SSH — 运维平台连接大量未知子机,默认跳过主机密钥校验(安装向导可在 .env 写 true 覆盖)
|
||||
SSH_STRICT_HOST_CHECKING: str = "false"
|
||||
|
||||
# Browser RDP (guacd sidecar)
|
||||
GUACD_HOST: str = ""
|
||||
GUACD_PORT: int = 4822
|
||||
|
||||
# JWT (mutable via .env / settings table — not immutable secrets)
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||
|
||||
@@ -202,6 +202,16 @@ class Admin(Base):
|
||||
token_version = Column(Integer, default=0, comment="令牌版本(重用时递增使旧token失效)")
|
||||
|
||||
|
||||
class AdminUiPreference(Base):
|
||||
"""Per-admin UI state (search history, etc.) — JSON payload keyed by context."""
|
||||
__tablename__ = "admin_ui_preferences"
|
||||
|
||||
admin_id = Column(Integer, ForeignKey("admins.id", ondelete="CASCADE"), primary_key=True)
|
||||
context = Column(String(50), primary_key=True, comment="UI 上下文,如 servers_search")
|
||||
payload = Column(JSON, nullable=False, comment='JSON: {"history":[],"last":null}')
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class LoginAttempt(Base):
|
||||
"""Login attempt log for brute-force protection"""
|
||||
__tablename__ = "login_attempts"
|
||||
@@ -530,22 +540,3 @@ class QuickCommand(Base):
|
||||
created_by = Column(String(100), nullable=True, comment="创建人")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# RDP Hosts (3389 独立远程桌面,不关联子机表)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class RdpHost(Base):
|
||||
"""Standalone Windows RDP endpoint for browser Guacamole (not tied to Server assets)."""
|
||||
__tablename__ = "rdp_hosts"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), unique=True, nullable=False, comment="显示名称")
|
||||
host = Column(String(255), nullable=False, comment="IP 或域名")
|
||||
port = Column(Integer, default=3389, nullable=False, comment="RDP 端口")
|
||||
username = Column(String(100), nullable=False, comment="RDP 用户名")
|
||||
password = Column(Text, nullable=False, comment="RDP 密码(Fernet 加密)")
|
||||
description = Column(Text, nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Admin UI preference repository — per-admin JSON blobs (search history, etc.)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import AdminUiPreference
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class AdminUiPreferenceRepositoryImpl:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get(self, admin_id: int, context: str) -> dict | None:
|
||||
result = await self.session.execute(
|
||||
select(AdminUiPreference).where(
|
||||
AdminUiPreference.admin_id == admin_id,
|
||||
AdminUiPreference.context == context,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row or not isinstance(row.payload, dict):
|
||||
return None
|
||||
return dict(row.payload)
|
||||
|
||||
async def upsert(self, admin_id: int, context: str, payload: dict) -> dict:
|
||||
result = await self.session.execute(
|
||||
select(AdminUiPreference).where(
|
||||
AdminUiPreference.admin_id == admin_id,
|
||||
AdminUiPreference.context == context,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
now = _utcnow()
|
||||
if row:
|
||||
row.payload = payload
|
||||
row.updated_at = now
|
||||
else:
|
||||
self.session.add(
|
||||
AdminUiPreference(
|
||||
admin_id=admin_id,
|
||||
context=context,
|
||||
payload=payload,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
return payload
|
||||
@@ -209,17 +209,14 @@ async def run_schema_migrations():
|
||||
UNIQUE KEY `uq_admin_refresh_token_hash` (`token_hash`),
|
||||
INDEX `idx_admin_refresh_admin_expires` (`admin_id`, `expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
"""CREATE TABLE IF NOT EXISTS `rdp_hosts` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '显示名称',
|
||||
`host` VARCHAR(255) NOT NULL COMMENT 'IP 或域名',
|
||||
`port` INT NOT NULL DEFAULT 3389 COMMENT 'RDP 端口',
|
||||
`username` VARCHAR(100) NOT NULL COMMENT 'RDP 用户名',
|
||||
`password` TEXT NOT NULL COMMENT 'RDP 密码(Fernet 加密)',
|
||||
`description` TEXT NULL COMMENT '备注',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
"DROP TABLE IF EXISTS `rdp_hosts`",
|
||||
"""CREATE TABLE IF NOT EXISTS `admin_ui_preferences` (
|
||||
`admin_id` INT NOT NULL COMMENT '管理员 ID',
|
||||
`context` VARCHAR(50) NOT NULL COMMENT 'UI 上下文',
|
||||
`payload` JSON NOT NULL COMMENT 'JSON 状态',
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uq_rdp_hosts_name` (`name`)
|
||||
PRIMARY KEY (`admin_id`, `context`),
|
||||
CONSTRAINT `fk_admin_ui_pref_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
||||
]
|
||||
async with AsyncSessionLocal() as session:
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"""RDP host repository (standalone 3389 endpoints)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.domain.models import RdpHost
|
||||
|
||||
|
||||
class RdpHostRepositoryImpl:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_by_id(self, host_id: int) -> Optional[RdpHost]:
|
||||
result = await self.session.execute(select(RdpHost).where(RdpHost.id == host_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str) -> Optional[RdpHost]:
|
||||
result = await self.session.execute(select(RdpHost).where(RdpHost.name == name))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_all(self) -> List[RdpHost]:
|
||||
result = await self.session.execute(select(RdpHost).order_by(RdpHost.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, row: RdpHost) -> RdpHost:
|
||||
self.session.add(row)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(row)
|
||||
return row
|
||||
|
||||
async def update(self, row: RdpHost) -> RdpHost:
|
||||
await self.session.commit()
|
||||
await self.session.refresh(row)
|
||||
return row
|
||||
|
||||
async def delete(self, host_id: int) -> bool:
|
||||
row = await self.get_by_id(host_id)
|
||||
if not row:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.commit()
|
||||
return True
|
||||
@@ -1 +0,0 @@
|
||||
"""Guacamole protocol bridge (guacd)."""
|
||||
@@ -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
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Async WebSocket ↔ guacd TCP bridge (Guacamole protocol relay)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
logger = logging.getLogger("nexus.guacamole.tunnel")
|
||||
|
||||
|
||||
async def bridge_websocket_to_guacd(
|
||||
websocket: WebSocket,
|
||||
guacd_host: str,
|
||||
guacd_port: int,
|
||||
) -> None:
|
||||
"""Relay Guacamole instructions between browser and guacd."""
|
||||
reader: asyncio.StreamReader
|
||||
writer: asyncio.StreamWriter
|
||||
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
|
||||
|
||||
async def ws_to_guacd() -> None:
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
writer.write(data.encode("utf-8"))
|
||||
await writer.drain()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug("RDP WS→guacd ended: %s", exc)
|
||||
|
||||
async def guacd_to_ws() -> None:
|
||||
try:
|
||||
while True:
|
||||
chunk = await reader.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
await websocket.send_text(chunk.decode("utf-8", errors="replace"))
|
||||
except Exception as exc:
|
||||
logger.debug("RDP guacd→WS ended: %s", exc)
|
||||
|
||||
ws_task = asyncio.create_task(ws_to_guacd())
|
||||
guacd_task = asyncio.create_task(guacd_to_ws())
|
||||
try:
|
||||
await asyncio.wait(
|
||||
[ws_task, guacd_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
finally:
|
||||
for task in (ws_task, guacd_task):
|
||||
task.cancel()
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Guacamole WebSocket tunnel: handshake with guacd then relay (not raw TCP bridge)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from server.infrastructure.guacamole.protocol import (
|
||||
GuacdStream,
|
||||
guacd_rdp_handshake,
|
||||
is_internal_instruction,
|
||||
tunnel_open_instruction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nexus.guacamole.ws_tunnel")
|
||||
|
||||
|
||||
async def run_guacamole_rdp_tunnel(
|
||||
websocket: WebSocket,
|
||||
guacd_host: str,
|
||||
guacd_port: int,
|
||||
connect_params: dict[str, str],
|
||||
*,
|
||||
width: int = 1024,
|
||||
height: int = 768,
|
||||
) -> None:
|
||||
"""Handshake with guacd using server-side credentials, then bridge WS ↔ guacd."""
|
||||
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
|
||||
stream = GuacdStream(reader)
|
||||
|
||||
try:
|
||||
conn_id = await guacd_rdp_handshake(
|
||||
stream, writer, connect_params, width=width, height=height,
|
||||
)
|
||||
await websocket.send_text(tunnel_open_instruction(conn_id))
|
||||
except Exception as exc:
|
||||
logger.warning("guacd handshake failed: %s", exc)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
await websocket.close(code=1011, reason=str(exc)[:120])
|
||||
return
|
||||
|
||||
async def ws_to_guacd() -> None:
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
if is_internal_instruction(data):
|
||||
# Tunnel stability pings use empty opcode — echo, do not forward to guacd.
|
||||
await websocket.send_text(data)
|
||||
continue
|
||||
writer.write(data.encode("utf-8"))
|
||||
await writer.drain()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug("RDP WS→guacd ended: %s", exc)
|
||||
|
||||
async def guacd_to_ws() -> None:
|
||||
try:
|
||||
while True:
|
||||
# Must not split mid-instruction — guacamole-common-js parses per WS message.
|
||||
batch = await stream.read_raw_batch()
|
||||
if not batch:
|
||||
break
|
||||
await websocket.send_text(batch.decode("utf-8", errors="replace"))
|
||||
except ConnectionError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug("RDP guacd→WS ended: %s", exc)
|
||||
|
||||
ws_task = asyncio.create_task(ws_to_guacd())
|
||||
guacd_task = asyncio.create_task(guacd_to_ws())
|
||||
try:
|
||||
await asyncio.wait([ws_task, guacd_task], return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for task in (ws_task, guacd_task):
|
||||
task.cancel()
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -38,6 +38,21 @@ async def publish_setting_change(db_key: str, value: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def publish_setting_changes(changes: dict[str, str]) -> int:
|
||||
"""Apply in-memory overrides and broadcast each key to all workers.
|
||||
|
||||
Returns the number of keys successfully published.
|
||||
"""
|
||||
from server.config import settings
|
||||
|
||||
published = 0
|
||||
for db_key, value in changes.items():
|
||||
if settings.apply_db_override(db_key, value):
|
||||
if await publish_setting_change(db_key, value):
|
||||
published += 1
|
||||
return published
|
||||
|
||||
|
||||
async def start_settings_subscriber() -> None:
|
||||
"""Subscribe to nexus:settings — called during app startup (every worker)."""
|
||||
global _subscriber_task, _redis_subscriber
|
||||
|
||||
@@ -33,17 +33,17 @@ async def probe_files_capability(server: Server) -> dict:
|
||||
|
||||
whitelist_probe = await exec_ssh_command(
|
||||
server,
|
||||
"sudo -n bash -c 'command -v chmod >/dev/null && command -v chown >/dev/null'",
|
||||
"sudo -n bash -c 'command -v chmod >/dev/null && command -v chown >/dev/null && command -v rsync >/dev/null'",
|
||||
timeout=15,
|
||||
)
|
||||
whitelist_ok = can_sudo_nopasswd and whitelist_probe.get("exit_code") == 0
|
||||
|
||||
if can_sudo_nopasswd and whitelist_ok:
|
||||
message = "已配置免密 sudo,文件管理可自动提权"
|
||||
message = "已配置免密 sudo,文件管理与推送可自动提权"
|
||||
elif can_sudo_nopasswd:
|
||||
message = (
|
||||
"可免密 sudo,但 chmod/chown 可能未列入白名单;"
|
||||
"请参考仓库内 sudoers 示例补全"
|
||||
"可免密 sudo,但 chmod/chown/rsync 可能未列入白名单;"
|
||||
"文件推送至 www 目录会失败,请参考仓库内 sudoers 示例补全"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
|
||||
+5
-6
@@ -67,8 +67,6 @@ from server.api.websocket import router as websocket_router
|
||||
from server.api.health import router as health_router
|
||||
from server.api.assets import router as assets_router
|
||||
from server.api.webssh import router as webssh_router
|
||||
from server.api.rdp import router as rdp_router
|
||||
from server.api.rdp_hosts import router as rdp_hosts_router
|
||||
from server.api.sync_v2 import router as sync_v2_router
|
||||
from server.api.files import router as files_router
|
||||
from server.api.search import router as search_router
|
||||
@@ -677,10 +675,6 @@ app.include_router(assets_router)
|
||||
# Web SSH Terminal
|
||||
app.include_router(webssh_router)
|
||||
|
||||
# Browser RDP (guacd tunnel + standalone hosts)
|
||||
app.include_router(rdp_router)
|
||||
app.include_router(rdp_hosts_router)
|
||||
|
||||
# Sync Engine v2 (Step 4)
|
||||
app.include_router(sync_v2_router)
|
||||
|
||||
@@ -693,6 +687,11 @@ app.include_router(search_router)
|
||||
# Terminal Quick Commands
|
||||
app.include_router(terminal_router)
|
||||
|
||||
# Embedded browser UI state
|
||||
from server.api.browser import router as browser_router # noqa: E402
|
||||
|
||||
app.include_router(browser_router)
|
||||
|
||||
|
||||
# ── Custom 404: return blank HTML to hide backend identity ──
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 != "")
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
@@ -133,11 +133,12 @@ def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str,
|
||||
"""Clone Pydantic error dicts with translated ``msg`` and ``loc_zh``."""
|
||||
translated: list[dict[str, Any]] = []
|
||||
for err in errors:
|
||||
item = dict(err)
|
||||
ctx = err.get("ctx") if isinstance(err.get("ctx"), dict) else None
|
||||
item = {k: v for k, v in err.items() if k != "ctx"}
|
||||
item["msg"] = translate_validation_msg(
|
||||
str(err.get("msg", "")),
|
||||
str(err.get("type", "")),
|
||||
err.get("ctx") if isinstance(err.get("ctx"), dict) else None,
|
||||
ctx,
|
||||
)
|
||||
item["loc_zh"] = translate_loc_zh(err.get("loc"))
|
||||
translated.append(item)
|
||||
|
||||
@@ -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"
|
||||
@@ -22,6 +22,12 @@ def test_compute_browse_etag_stable():
|
||||
assert len(e1) > 10
|
||||
|
||||
|
||||
def test_compute_browse_etag_changes_when_permissions_change():
|
||||
base = [{"name": "a.txt", "modified": "2026-01-01", "size": 10, "permissions": "-rw-r--r--", "owner": "www", "group": "www", "mode_octal": "644"}]
|
||||
changed = [{**base[0], "permissions": "-rwxr-xr-x", "mode_octal": "755"}]
|
||||
assert compute_browse_etag("/var/www", base) != compute_browse_etag("/var/www", changed)
|
||||
|
||||
|
||||
def test_browse_server_cache_304_path():
|
||||
payload = {"path": "/tmp", "items": [{"name": "x", "modified": "t", "size": 1}]}
|
||||
etag = compute_browse_etag("/tmp", payload["items"])
|
||||
|
||||
@@ -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 "")
|
||||
@@ -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";")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user