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