Remove browser RDP (guacd/Guacamole) feature entirely.

Product decision: drop 3389 remote desktop page, API, guacd sidecar, and related tests after unresolved target-side black screen issues.
This commit is contained in:
Nexus Agent
2026-06-10 23:23:09 +08:00
parent 341b130d9b
commit dc4593f8b5
34 changed files with 151 additions and 2052 deletions
-8
View File
@@ -40,10 +40,6 @@ services:
ports:
- "${REDIS_PUBLISH_PORT:-6379}:6379"
guacd:
image: guacamole/guacd:1.5.5
restart: unless-stopped
nexus:
build:
context: .
@@ -54,8 +50,6 @@ services:
condition: service_healthy
redis:
condition: service_healthy
guacd:
condition: service_started
ports:
- "${NEXUS_PUBLISH_PORT:-8600}:8600"
environment:
@@ -75,8 +69,6 @@ services:
NEXUS_API_KEY: ${NEXUS_API_KEY:-}
NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:-}
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:-http://localhost:8600}
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
env_file:
- path: docker/.env
required: false
-10
View File
@@ -11,15 +11,7 @@
name: nexus-prod
services:
guacd:
image: guacamole/guacd:1.5.5
restart: unless-stopped
networks:
- nexus-internal
nexus:
depends_on:
- guacd
build:
context: ..
dockerfile: Dockerfile.prod
@@ -46,8 +38,6 @@ services:
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:?set NEXUS_API_BASE_URL}
NEXUS_DB_POOL_SIZE: ${NEXUS_DB_POOL_SIZE:-30}
NEXUS_DB_MAX_OVERFLOW: ${NEXUS_DB_MAX_OVERFLOW:-20}
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
volumes:
- nexus-state:/var/lib/nexus
- nexus-web-data:/app/web/data
@@ -0,0 +1,65 @@
# 审计 — 移除浏览器 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/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,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 或回仪表盘(无路由)。
+34
View File
@@ -23,8 +23,42 @@ const SIDEBAR_LINKS = {
'/audit': '审计日志',
}
const E2E_ACCESS_TOKEN = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
/** Mock refresh + profile so Pinia bootstraps with a pre-minted JWT (bypass login IP allowlist). */
export async function loginWithAccessToken(page, accessToken = E2E_ACCESS_TOKEN) {
if (!accessToken) {
throw new Error('Set NEXUS_E2E_ACCESS_TOKEN for token-based E2E login')
}
await page.route('**/api/auth/refresh', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ access_token: accessToken }),
})
})
await page.route('**/api/auth/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 1,
username: USER,
totp_enabled: false,
created_at: '2020-01-01T00:00:00Z',
}),
})
})
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
await expect(page.getByRole('button', { name: USER })).toBeVisible({ timeout: 15000 })
}
/** Restore session from worker refresh cookie (avoids parallel UI logins / lockout). */
export async function login(page) {
if (E2E_ACCESS_TOKEN) {
return loginWithAccessToken(page, E2E_ACCESS_TOKEN)
}
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
await expect(page.getByRole('button', { name: 'admin' })).toBeVisible({ timeout: 15000 })
-6
View File
@@ -14,7 +14,6 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"echarts": "^6.1.0",
"guacamole-common-js": "^1.5.0",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4",
"vue": "^3.5.30",
@@ -1344,11 +1343,6 @@
"node": ">= 6"
}
},
"node_modules/guacamole-common-js": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz",
"integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg=="
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-1
View File
@@ -18,7 +18,6 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"echarts": "^6.1.0",
"guacamole-common-js": "^1.5.0",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.4",
"vue": "^3.5.30",
+2 -3
View File
@@ -326,8 +326,8 @@ const { mobile } = useDisplay()
provide('nexusDrawer', drawer)
const mainLayoutClass = computed(() => ({
'nexus-terminal-main': route.path === '/terminal' || route.path === '/rdp',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/rdp' && route.path !== '/login',
'nexus-terminal-main': route.path === '/terminal',
'nexus-page-main': route.path !== '/terminal' && route.path !== '/login',
}))
const scriptExecBarOffset = computed(() => {
@@ -343,7 +343,6 @@ const opsItems = [
{ to: '/', title: '仪表盘', icon: 'mdi-view-dashboard-outline' },
{ to: '/servers', title: '服务器', icon: 'mdi-server' },
{ to: '/terminal', title: '终端', icon: 'mdi-console' },
{ to: '/rdp', title: '3389远程', icon: 'mdi-remote-desktop' },
{ to: '/files', title: '文件管理', icon: 'mdi-folder-outline' },
{ to: '/push', title: '推送', icon: 'mdi-upload-outline' },
{ to: '/scripts', title: '脚本库', icon: 'mdi-code-braces' },
+1 -1
View File
@@ -190,7 +190,7 @@ function filenameFromContentDisposition(header: string | null): string | null {
return plain?.[1] ?? null
}
/** GET binary download (e.g. /servers/{id}/rdp-file). */
/** GET binary download (e.g. exported files). */
export async function downloadGet(path: string): Promise<{ blob: Blob; filename: string }> {
const res = await fetchAuthed(path, { method: 'GET' })
if (res.status === 202) {
@@ -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>
@@ -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,175 +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('&')
}
function tunnelErrorMessage(st: { code?: number; message?: string }): string {
const msg = (st?.message || '').trim()
if (/server timeout/i.test(msg)) {
return '连接超时:服务端未收到远程桌面数据,请稍后重试'
}
return msg || `RDP 隧道错误 (${st?.code ?? 'unknown'})`
}
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)
}
function mountDisplay(rdpClient: InstanceType<typeof Guacamole.Client>) {
if (!mountTarget) return
mountTarget.innerHTML = ''
const el = rdpClient.getDisplay().getElement()
mountTarget.appendChild(el)
displayEl.value = el
resizeObserver?.disconnect()
resizeObserver = new ResizeObserver(() => sendDisplaySize())
resizeObserver.observe(mountTarget)
sendDisplaySize()
}
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'
const wsUrl = buildWebSocketUrl(
`/ws/rdp/hosts/${hostId}/tunnel/${encodeURIComponent(auth.token)}`,
)
const rdpTunnel = new Guacamole.WebSocketTunnel(wsUrl)
// RDP handshake to Azure can exceed the default 15s; pings need timely echo from server.
rdpTunnel.receiveTimeout = 90_000
rdpTunnel.unstableThreshold = 15_000
tunnel = rdpTunnel
const rdpClient = new Guacamole.Client(rdpTunnel)
client = rdpClient
const fail = (message: string) => {
status.value = 'error'
errorMessage.value = message
disconnect()
}
rdpTunnel.onerror = (st) => fail(tunnelErrorMessage(st))
rdpClient.onerror = (st) => fail(tunnelErrorMessage(st))
rdpClient.onstatechange = (state: number) => {
if (state === Guacamole.Client.STATE_WAITING) {
status.value = 'connecting'
} else if (state === Guacamole.Client.STATE_CONNECTED) {
status.value = 'connected'
sendDisplaySize()
} else if (state === Guacamole.Client.STATE_DISCONNECTED) {
if (status.value !== 'error') status.value = 'disconnected'
}
}
rdpTunnel.onstatechange = (state: number) => {
// Guacamole.Tunnel.State.OPEN === 1
if (state === 1 && status.value === 'connecting') {
sendDisplaySize()
}
}
const display = rdpClient.getDisplay()
display.onresize = () => sendDisplaySize()
// Display must be in DOM before connect so sync/flush can complete (Guacamole convention).
mountDisplay(rdpClient)
rdpClient.connect(buildConnectString(info))
}
onBeforeUnmount(() => disconnect())
return {
status,
errorMessage,
serverName,
connect,
disconnect,
sendDisplaySize,
}
}
@@ -2,7 +2,6 @@ const ROUTE_CHUNKS: Record<string, () => Promise<unknown>> = {
'/': () => import('@/pages/DashboardPage.vue'),
'/servers': () => import('@/pages/ServersPage.vue'),
'/terminal': () => import('@/pages/TerminalPage.vue'),
'/rdp': () => import('@/pages/RdpPage.vue'),
'/files': () => import('@/pages/FilesPage.vue'),
'/push': () => import('@/pages/PushPage.vue'),
'/scripts': () => import('@/pages/ScriptsPage.vue'),
-333
View File
@@ -1,333 +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>
<template v-else>
<div ref="canvasHost" class="rdp-canvas-host" />
<div
v-if="status === 'loading' || status === 'connecting'"
class="rdp-overlay 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-overlay 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>
</template>
</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, nextTick } 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 canvasHost = 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
if (!id) return
await nextTick()
const host = canvasHost.value
if (!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-canvas-host {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.rdp-canvas-host :deep(canvas) {
display: block;
margin: 0 auto;
}
.rdp-overlay {
position: absolute;
inset: 0;
z-index: 2;
background: #1a1a1a;
}
.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>
-1
View File
@@ -5,7 +5,6 @@ const routes = [
{ path: '/', name: 'Dashboard', component: () => import('@/pages/DashboardPage.vue') },
{ path: '/servers', name: 'Servers', component: () => import('@/pages/ServersPage.vue') },
{ path: '/terminal', name: 'Terminal', component: () => import('@/pages/TerminalPage.vue') },
{ path: '/rdp', name: 'Rdp', component: () => import('@/pages/RdpPage.vue') },
{ path: '/files', name: 'Files', component: () => import('@/pages/FilesPage.vue') },
{ path: '/push', name: 'Push', component: () => import('@/pages/PushPage.vue') },
{ path: '/scripts', name: 'Scripts', component: () => import('@/pages/ScriptsPage.vue') },
-4
View File
@@ -161,10 +161,6 @@ export interface ServerApiItem {
platform_id: number | null
node_id: number | null
files_elevation?: FilesElevationMode
rdp_enabled?: boolean
rdp_port?: number
rdp_username?: string
rdp_password_set?: boolean
is_online: boolean
last_heartbeat: string | null
agent_version: string | null
-39
View File
@@ -1,39 +0,0 @@
declare module 'guacamole-common-js' {
class Client {
static STATE_IDLE: number
static STATE_CONNECTING: number
static STATE_WAITING: number
static STATE_CONNECTED: number
static STATE_DISCONNECTING: number
static STATE_DISCONNECTED: number
constructor(tunnel: Tunnel)
connect(data?: string): void
disconnect(): void
getDisplay(): Display
onstatechange: ((state: number) => void) | null
onerror: ((status: { code?: number; message?: string }) => void) | null
sendSize(width: number, height: number): void
}
class Display {
getElement(): HTMLElement
onresize: ((width: number, height: number) => void) | null
scale(scale: number): void
}
class Tunnel {
onstatechange: ((state: number) => void) | null
onerror: ((status: { code?: number; message?: string }) => void) | null
}
class WebSocketTunnel extends Tunnel {
constructor(url: string)
}
const Guacamole: {
Client: typeof Client
WebSocketTunnel: typeof WebSocketTunnel
}
export default Guacamole
}
-159
View File
@@ -1,159 +0,0 @@
"""Browser RDP — Guacamole WebSocket tunnel to guacd (no session audit)."""
from __future__ import annotations
import logging
from typing import Optional
from urllib.parse import parse_qs, unquote
from fastapi import APIRouter, Path, WebSocket, WebSocketDisconnect
from server.config import settings
from server.domain.models import Admin
from server.infrastructure.database.crypto import decrypt_value
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
from server.infrastructure.database.rdp_host_repo import RdpHostRepositoryImpl
from server.infrastructure.guacamole.ws_tunnel import run_guacamole_rdp_tunnel
logger = logging.getLogger("nexus.rdp")
router = APIRouter()
async def _verify_rdp_access_token(token: str) -> Optional[Admin]:
"""Verify normal access JWT for RDP (reject webssh-only tokens)."""
try:
import jwt as pyjwt
payload = pyjwt.decode(
token,
settings.SECRET_KEY,
algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
except Exception:
return None
if payload.get("purpose") == "webssh":
return None
admin_id = payload.get("sub")
if not admin_id:
return None
try:
admin_id = int(admin_id)
except (TypeError, ValueError):
return None
async with AsyncSessionLocal() as session:
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
if not admin or not admin.is_active:
return None
token_tv = payload.get("tv") or 0
if token_tv != (admin.token_version or 0):
return None
return admin
def _rdp_password_plain(enc: str) -> str:
plain = str(enc or "").strip()
if not plain:
return ""
try:
return decrypt_value(plain)
except Exception:
return ""
def _parse_display_size(query_string: bytes) -> tuple[int, int]:
qs = parse_qs(query_string.decode("utf-8", errors="replace"))
try:
w = int(qs.get("width", ["1024"])[0])
h = int(qs.get("height", ["768"])[0])
if w > 0 and h > 0:
return w, h
except (TypeError, ValueError):
pass
return 1024, 768
@router.websocket("/ws/rdp/hosts/{host_id}/tunnel/{access_token}")
async def rdp_guacamole_ws(
websocket: WebSocket,
host_id: int = Path(...),
access_token: str = Path(..., description="URL-encoded JWT (not query — Guacamole appends ?connect)"),
):
"""Guacamole WS tunnel: JWT auth, server-side guacd handshake, then relay."""
token = unquote(access_token).strip()
if not token:
await websocket.close(code=4001, reason="Missing JWT token")
return
admin = await _verify_rdp_access_token(token)
if not admin:
await websocket.close(code=4401, reason="Invalid or expired JWT token")
return
if not settings.GUACD_HOST:
await websocket.close(code=4503, reason="guacd not configured")
return
async with AsyncSessionLocal() as session:
repo = RdpHostRepositoryImpl(session)
row = await repo.get_by_id(host_id)
if not row:
await websocket.close(code=4404, reason="RDP host not found")
return
password = _rdp_password_plain(row.password)
if not password:
await websocket.close(code=4400, reason="RDP password not configured")
return
connect_params = {
k: v
for k, v in (
("hostname", row.host.strip()),
("port", str(row.port)),
("username", row.username.strip()),
("password", password),
("security", "any"),
("ignore-cert", "true"),
("disable-wallpaper", "true"),
("disable-theming", "true"),
("enable-font-smoothing", "true"),
("enable-full-window-drag", "false"),
("enable-desktop-composition", "false"),
("enable-menu-animations", "false"),
)
if v
}
width, height = _parse_display_size(websocket.scope.get("query_string", b""))
await websocket.accept(subprotocol="guacamole")
try:
await run_guacamole_rdp_tunnel(
websocket,
settings.GUACD_HOST,
settings.GUACD_PORT,
connect_params,
width=width,
height=height,
)
except WebSocketDisconnect:
pass
except OSError as exc:
logger.warning("RDP guacd tunnel failed host_id=%s: %s", host_id, exc)
try:
await websocket.close(code=1011, reason="guacd unreachable")
except Exception:
pass
except Exception:
logger.exception("RDP tunnel error host_id=%s", host_id)
try:
await websocket.close(code=1011, reason="RDP tunnel error")
except Exception:
pass
-241
View File
@@ -1,241 +0,0 @@
"""Standalone RDP host CRUD + connect params (3389 page, not Server assets)."""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from server.api.auth_jwt import get_current_admin
from server.api.dependencies import get_db
from server.config import settings
from server.domain.models import Admin, AuditLog, RdpHost
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.crypto import decrypt_value, encrypt_value
from server.infrastructure.database.rdp_host_repo import RdpHostRepositoryImpl
from server.utils.rdp_config import DEFAULT_RDP_PORT, normalize_rdp_port
router = APIRouter(prefix="/api/rdp/hosts", tags=["rdp"])
class RdpHostCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
host: str = Field(..., min_length=1, max_length=255)
port: int = Field(DEFAULT_RDP_PORT, ge=1, le=65535)
username: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=1)
description: Optional[str] = Field(None, max_length=2000)
class RdpHostUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
host: Optional[str] = Field(None, min_length=1, max_length=255)
port: Optional[int] = Field(None, ge=1, le=65535)
username: Optional[str] = Field(None, min_length=1, max_length=100)
password: Optional[str] = None
description: Optional[str] = Field(None, max_length=2000)
def _password_set(enc: str | None) -> bool:
return bool(str(enc or "").strip())
def _host_to_dict(row: RdpHost) -> dict:
return {
"id": row.id,
"name": row.name,
"host": row.host,
"port": row.port,
"username": row.username,
"password_set": _password_set(row.password),
"description": row.description or "",
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
def _decrypt_password(row: RdpHost) -> str:
enc = str(row.password or "").strip()
if not enc:
return ""
try:
return decrypt_value(enc)
except Exception:
return ""
async def _audit(
db: AsyncSession,
*,
admin: Admin,
action: str,
host_id: int,
detail: str,
request: Request,
) -> None:
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(
AuditLog(
admin_username=admin.username,
action=action,
target_type="rdp_host",
target_id=host_id,
detail=detail,
ip_address=request.client.host if request.client else "",
)
)
@router.get("/", response_model=dict)
async def list_rdp_hosts(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = RdpHostRepositoryImpl(db)
rows = await repo.list_all()
return {"items": [_host_to_dict(r) for r in rows], "total": len(rows)}
@router.post("/", response_model=dict, status_code=201)
async def create_rdp_host(
payload: RdpHostCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = RdpHostRepositoryImpl(db)
name = payload.name.strip()
host = payload.host.strip()
username = payload.username.strip()
password = payload.password.strip()
if not password:
raise HTTPException(status_code=400, detail="请填写 RDP 密码")
row = RdpHost(
name=name,
host=host,
port=normalize_rdp_port(payload.port),
username=username,
password=encrypt_value(password),
description=(payload.description or "").strip() or None,
)
try:
created = await repo.create(row)
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail=f"名称「{name}」已存在")
await _audit(
db,
admin=admin,
action="create_rdp_host",
host_id=created.id,
detail=f"名称={name} 地址={host}:{row.port}",
request=request,
)
return _host_to_dict(created)
@router.put("/{host_id}", response_model=dict)
async def update_rdp_host(
host_id: int,
payload: RdpHostUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = RdpHostRepositoryImpl(db)
row = await repo.get_by_id(host_id)
if not row:
raise HTTPException(status_code=404, detail="RDP 主机不存在")
data = payload.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
row.name = data["name"].strip()
if "host" in data and data["host"] is not None:
row.host = data["host"].strip()
if "port" in data and data["port"] is not None:
row.port = normalize_rdp_port(data["port"])
if "username" in data and data["username"] is not None:
row.username = data["username"].strip()
if "description" in data:
row.description = (data["description"] or "").strip() or None
if "password" in data and data["password"] is not None:
plain = str(data["password"]).strip()
if plain:
row.password = encrypt_value(plain)
try:
updated = await repo.update(row)
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail=f"名称「{row.name}」已存在")
await _audit(
db,
admin=admin,
action="update_rdp_host",
host_id=host_id,
detail=f"名称={updated.name} 地址={updated.host}:{updated.port}",
request=request,
)
return _host_to_dict(updated)
@router.delete("/{host_id}", status_code=204)
async def delete_rdp_host(
host_id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
repo = RdpHostRepositoryImpl(db)
row = await repo.get_by_id(host_id)
if not row:
raise HTTPException(status_code=404, detail="RDP 主机不存在")
name = row.name
if not await repo.delete(host_id):
raise HTTPException(status_code=404, detail="RDP 主机不存在")
await _audit(
db,
admin=admin,
action="delete_rdp_host",
host_id=host_id,
detail=f"名称={name}",
request=request,
)
@router.get("/{host_id}/connect", response_model=dict)
async def get_rdp_host_connect_params(
host_id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
if not settings.GUACD_HOST:
raise HTTPException(status_code=503, detail="服务端未配置 guacd,无法使用浏览器 RDP")
repo = RdpHostRepositoryImpl(db)
row = await repo.get_by_id(host_id)
if not row:
raise HTTPException(status_code=404, detail="RDP 主机不存在")
host = (row.host or "").strip()
if not host:
raise HTTPException(status_code=400, detail="缺少地址(IP/域名)")
password = _decrypt_password(row)
if not password:
raise HTTPException(status_code=400, detail="RDP 密码无效或未设置")
return {
"host_id": row.id,
"name": row.name,
"hostname": host,
"port": row.port,
"username": row.username,
}
-40
View File
@@ -78,26 +78,6 @@ class ServerCreate(BaseModel):
pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)",
)
rdp_enabled: Optional[bool] = Field(
default=None,
description="Enable native RDP launch (.rdp download); stored in extra_attrs",
)
rdp_port: Optional[int] = Field(
default=None,
ge=1,
le=65535,
description="RDP port (default 3389); stored in extra_attrs",
)
rdp_username: Optional[str] = Field(
default=None,
max_length=100,
description="RDP username; stored in extra_attrs",
)
rdp_password: Optional[str] = Field(
default=None,
description="RDP password (encrypted in extra_attrs); write-only",
)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
@@ -130,26 +110,6 @@ class ServerUpdate(BaseModel):
pattern="^(off|auto_sudo|always_sudo)$",
description="File manager sudo policy (stored in extra_attrs)",
)
rdp_enabled: Optional[bool] = Field(
default=None,
description="Enable native RDP launch (.rdp download); stored in extra_attrs",
)
rdp_port: Optional[int] = Field(
default=None,
ge=1,
le=65535,
description="RDP port (default 3389); stored in extra_attrs",
)
rdp_username: Optional[str] = Field(
default=None,
max_length=100,
description="RDP username; stored in extra_attrs",
)
rdp_password: Optional[str] = Field(
default=None,
description="RDP password (encrypted in extra_attrs); write-only",
)
@field_validator("target_path", mode="before")
@classmethod
def _normalize_target_path(cls, v: object) -> object:
-49
View File
@@ -1138,44 +1138,6 @@ async def get_server(
return server_data
@router.get("/{id}/rdp-connect", response_model=dict)
async def get_rdp_connect_params(
id: int,
admin: Admin = Depends(get_current_admin),
service: ServerService = Depends(get_server_service),
):
"""RDP parameters for in-browser Guacamole client (admin JWT; no session audit)."""
from server.config import settings
from server.utils.rdp_config import get_rdp_config, get_rdp_password_plain
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
cfg = get_rdp_config(server)
if not cfg.enabled:
raise HTTPException(status_code=400, detail="此服务器未启用 RDP")
host = (server.domain or "").strip()
if not host:
raise HTTPException(status_code=400, detail="服务器缺少地址(域名/IP")
password = get_rdp_password_plain(server)
if not password:
raise HTTPException(status_code=400, detail="请先在服务器设置中填写 RDP 密码")
if not settings.GUACD_HOST:
raise HTTPException(status_code=503, detail="服务端未配置 guacd,无法使用浏览器 RDP")
return {
"server_id": server.id,
"server_name": server.name,
"hostname": host,
"port": cfg.port,
"username": cfg.username,
"password": password,
}
@router.get("/{id}/metrics", response_model=dict)
async def get_server_metrics(
id: int,
@@ -1342,11 +1304,9 @@ async def create_server(
from server.infrastructure.database.ssh_key_preset_repo import SshKeyPresetRepositoryImpl
from server.utils.files_elevation import apply_files_elevation_payload
from server.utils.rdp_config import apply_rdp_payload
data = payload.model_dump(exclude_none=True)
apply_files_elevation_payload(data)
apply_rdp_payload(data)
# If preset_id is provided, resolve the preset password (server-side, no re-auth needed)
if data.get("preset_id") and data.get("auth_method") == "password" and not data.get("password"):
@@ -1417,12 +1377,10 @@ async def update_server(
raise HTTPException(status_code=404, detail="Server not found")
from server.utils.files_elevation import apply_files_elevation_payload
from server.utils.rdp_config import apply_rdp_payload
# If preset_id is provided (and not None), resolve the preset password
update_data = payload.model_dump(exclude_unset=True)
apply_files_elevation_payload(update_data)
apply_rdp_payload(update_data, existing_extra=server.extra_attrs)
if "preset_id" in update_data and update_data["preset_id"] is not None and not update_data.get("password"):
preset_repo = PasswordPresetRepositoryImpl(db)
preset = await preset_repo.get_by_id(update_data["preset_id"])
@@ -2059,9 +2017,6 @@ def _apply_heartbeat_overlay(server_data: dict, server: Server, heartbeat: dict
def _server_to_dict(server: Server) -> dict:
"""Convert Server model to API response dict (hide sensitive fields)"""
from server.utils.files_elevation import get_files_elevation
from server.utils.rdp_config import get_rdp_config
rdp = get_rdp_config(server)
return {
"id": server.id,
@@ -2087,10 +2042,6 @@ def _server_to_dict(server: Server) -> dict:
"protocols": server.protocols,
"extra_attrs": server.extra_attrs,
"files_elevation": get_files_elevation(server).value,
"rdp_enabled": rdp.enabled,
"rdp_port": rdp.port,
"rdp_username": rdp.username,
"rdp_password_set": rdp.password_set,
"connectivity": server.connectivity,
"ssh_key_configured": server.ssh_key_configured,
"is_online": server.is_online,
-4
View File
@@ -57,10 +57,6 @@ class Settings(BaseSettings):
# SSH — 运维平台连接大量未知子机,默认跳过主机密钥校验(安装向导可在 .env 写 true 覆盖)
SSH_STRICT_HOST_CHECKING: str = "false"
# Browser RDP (guacd sidecar)
GUACD_HOST: str = ""
GUACD_PORT: int = 4822
# JWT (mutable via .env / settings table — not immutable secrets)
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
-19
View File
@@ -530,22 +530,3 @@ class QuickCommand(Base):
created_by = Column(String(100), nullable=True, comment="创建人")
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
# ──────────────────────────────────────────────
# RDP Hosts (3389 独立远程桌面,不关联子机表)
# ──────────────────────────────────────────────
class RdpHost(Base):
"""Standalone Windows RDP endpoint for browser Guacamole (not tied to Server assets)."""
__tablename__ = "rdp_hosts"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False, comment="显示名称")
host = Column(String(255), nullable=False, comment="IP 或域名")
port = Column(Integer, default=3389, nullable=False, comment="RDP 端口")
username = Column(String(100), nullable=False, comment="RDP 用户名")
password = Column(Text, nullable=False, comment="RDP 密码(Fernet 加密)")
description = Column(Text, nullable=True, comment="备注")
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
+1 -12
View File
@@ -209,18 +209,7 @@ async def run_schema_migrations():
UNIQUE KEY `uq_admin_refresh_token_hash` (`token_hash`),
INDEX `idx_admin_refresh_admin_expires` (`admin_id`, `expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"""CREATE TABLE IF NOT EXISTS `rdp_hosts` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL COMMENT '显示名称',
`host` VARCHAR(255) NOT NULL COMMENT 'IP 或域名',
`port` INT NOT NULL DEFAULT 3389 COMMENT 'RDP 端口',
`username` VARCHAR(100) NOT NULL COMMENT 'RDP 用户名',
`password` TEXT NOT NULL COMMENT 'RDP 密码(Fernet 加密)',
`description` TEXT NULL COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uq_rdp_hosts_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
"DROP TABLE IF EXISTS `rdp_hosts`",
]
async with AsyncSessionLocal() as session:
for sql in migrations:
@@ -1,46 +0,0 @@
"""RDP host repository (standalone 3389 endpoints)."""
from __future__ import annotations
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import RdpHost
class RdpHostRepositoryImpl:
def __init__(self, session: AsyncSession):
self.session = session
async def get_by_id(self, host_id: int) -> Optional[RdpHost]:
result = await self.session.execute(select(RdpHost).where(RdpHost.id == host_id))
return result.scalar_one_or_none()
async def get_by_name(self, name: str) -> Optional[RdpHost]:
result = await self.session.execute(select(RdpHost).where(RdpHost.name == name))
return result.scalar_one_or_none()
async def list_all(self) -> List[RdpHost]:
result = await self.session.execute(select(RdpHost).order_by(RdpHost.name))
return list(result.scalars().all())
async def create(self, row: RdpHost) -> RdpHost:
self.session.add(row)
await self.session.commit()
await self.session.refresh(row)
return row
async def update(self, row: RdpHost) -> RdpHost:
await self.session.commit()
await self.session.refresh(row)
return row
async def delete(self, host_id: int) -> bool:
row = await self.get_by_id(host_id)
if not row:
return False
await self.session.delete(row)
await self.session.commit()
return True
@@ -1 +0,0 @@
"""Guacamole protocol bridge (guacd)."""
-164
View File
@@ -1,164 +0,0 @@
"""Guacamole protocol helpers (instruction encode/decode + guacd handshake)."""
from __future__ import annotations
import asyncio
import logging
import uuid
from typing import Optional
logger = logging.getLogger("nexus.guacamole.protocol")
def encode_element(value: str) -> str:
s = value if value is not None else ""
return f"{len(s)}.{s}"
def encode_instruction(opcode: str, *args: str) -> str:
parts = [encode_element(opcode)] + [encode_element(a) for a in args]
return ",".join(parts) + ";"
def parse_instruction(message: str) -> tuple[str, list[str]]:
"""Parse one complete instruction (without trailing semicolon)."""
elements: list[str] = []
i = 0
length = len(message)
while i < length:
dot = message.find(".", i)
if dot < 0:
raise ValueError(f"invalid guacamole instruction at {i}")
el_len = int(message[i:dot])
start = dot + 1
end = start + el_len
elements.append(message[start:end])
i = end
if i < length and message[i] == ",":
i += 1
else:
break
if not elements:
raise ValueError("empty guacamole instruction")
return elements[0], elements[1:]
class GuacdStream:
"""Buffered guacd TCP reader — one instruction per read()."""
def __init__(self, reader: asyncio.StreamReader):
self._reader = reader
self._buf = b""
async def read_instruction(self) -> tuple[str, list[str]]:
while True:
semi = self._buf.find(b";")
if semi >= 0:
raw = self._buf[:semi]
self._buf = self._buf[semi + 1 :]
msg = raw.decode("utf-8", errors="replace")
return parse_instruction(msg)
chunk = await self._reader.read(8192)
if not chunk:
raise ConnectionError("guacd closed during read")
self._buf += chunk
async def read_raw_instruction(self) -> bytes:
"""Read one complete guacd instruction (including trailing ``;``)."""
while True:
semi = self._buf.find(b";")
if semi >= 0:
raw = self._buf[: semi + 1]
self._buf = self._buf[semi + 1 :]
return raw
chunk = await self._reader.read(8192)
if not chunk:
raise ConnectionError("guacd closed during read")
self._buf += chunk
async def read_raw_batch(self) -> bytes:
"""Read all currently buffered complete instructions (may be multiple)."""
parts: list[bytes] = []
while True:
semi = self._buf.find(b";")
if semi < 0:
if not parts:
chunk = await self._reader.read(8192)
if not chunk:
if parts:
break
raise ConnectionError("guacd closed during read")
self._buf += chunk
continue
break
parts.append(await self.read_raw_instruction())
return b"".join(parts)
def _param_for_arg(arg_name: str, params: dict[str, str]) -> str:
"""Map guacd arg name to connection parameter value."""
normalized = arg_name.replace("_", "-").lower()
for key, value in params.items():
if key.replace("_", "-").lower() == normalized:
return value or ""
return ""
async def guacd_rdp_handshake(
stream: GuacdStream,
writer: asyncio.StreamWriter,
params: dict[str, str],
*,
width: int = 1024,
height: int = 768,
dpi: int = 96,
) -> str:
"""Run guacd handshake for RDP; return connection id from ``ready``."""
writer.write(encode_instruction("select", "rdp").encode("utf-8"))
await writer.drain()
arg_names: list[str] = []
while True:
opcode, args = await stream.read_instruction()
if opcode == "args":
arg_names = list(args)
break
if opcode == "error":
raise RuntimeError(args[0] if args else "guacd error during args")
logger.warning("guacd unexpected during select: %s %s", opcode, args)
writer.write(encode_instruction("size", str(width), str(height), str(dpi)).encode("utf-8"))
await writer.drain()
writer.write(encode_instruction("audio").encode("utf-8"))
await writer.drain()
writer.write(encode_instruction("video").encode("utf-8"))
await writer.drain()
connect_values = [_param_for_arg(name, params) for name in arg_names]
writer.write(encode_instruction("connect", *connect_values).encode("utf-8"))
await writer.drain()
while True:
opcode, args = await stream.read_instruction()
if opcode == "ready":
if not args:
raise RuntimeError("guacd ready without connection id")
return args[0]
if opcode == "error":
raise RuntimeError(args[0] if args else "guacd connection failed")
logger.debug("guacd during handshake: %s %s", opcode, args)
def tunnel_open_instruction(connection_id: Optional[str] = None) -> str:
"""Empty opcode + UUID — tells guacamole-common-js the tunnel is OPEN."""
cid = connection_id or str(uuid.uuid4())
return encode_instruction("", cid)
def is_internal_instruction(message: str) -> bool:
"""True if message uses internal (empty) opcode — not for guacd."""
try:
opcode, _ = parse_instruction(message.rstrip(";"))
return opcode == ""
except ValueError:
return False
-58
View File
@@ -1,58 +0,0 @@
"""Async WebSocket ↔ guacd TCP bridge (Guacamole protocol relay)."""
from __future__ import annotations
import asyncio
import logging
from fastapi import WebSocket, WebSocketDisconnect
logger = logging.getLogger("nexus.guacamole.tunnel")
async def bridge_websocket_to_guacd(
websocket: WebSocket,
guacd_host: str,
guacd_port: int,
) -> None:
"""Relay Guacamole instructions between browser and guacd."""
reader: asyncio.StreamReader
writer: asyncio.StreamWriter
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
async def ws_to_guacd() -> None:
try:
while True:
data = await websocket.receive_text()
writer.write(data.encode("utf-8"))
await writer.drain()
except WebSocketDisconnect:
pass
except Exception as exc:
logger.debug("RDP WS→guacd ended: %s", exc)
async def guacd_to_ws() -> None:
try:
while True:
chunk = await reader.read(8192)
if not chunk:
break
await websocket.send_text(chunk.decode("utf-8", errors="replace"))
except Exception as exc:
logger.debug("RDP guacd→WS ended: %s", exc)
ws_task = asyncio.create_task(ws_to_guacd())
guacd_task = asyncio.create_task(guacd_to_ws())
try:
await asyncio.wait(
[ws_task, guacd_task],
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for task in (ws_task, guacd_task):
task.cancel()
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
@@ -1,127 +0,0 @@
"""Guacamole WebSocket tunnel: handshake with guacd then relay (not raw TCP bridge)."""
from __future__ import annotations
import asyncio
import contextlib
import logging
from fastapi import WebSocket, WebSocketDisconnect
from server.infrastructure.guacamole.protocol import (
GuacdStream,
guacd_rdp_handshake,
is_internal_instruction,
iter_raw_instructions,
tunnel_open_instruction,
)
logger = logging.getLogger("nexus.guacamole.ws_tunnel")
async def _guacd_reader_loop(stream: GuacdStream, out: asyncio.Queue[bytes | None]) -> None:
"""Continuously read complete guacd instructions into a queue (never cancel mid-read)."""
try:
while True:
batch = await stream.read_raw_batch()
if not batch:
break
await out.put(batch)
except ConnectionError:
pass
except Exception as exc:
logger.warning("guacd reader ended: %s", exc)
finally:
await out.put(None)
async def _relay_ws_guacd(
websocket: WebSocket,
stream: GuacdStream,
writer: asyncio.StreamWriter,
) -> None:
"""Single WS consumer; guacd reads run in a dedicated task (Starlette-safe)."""
guacd_queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=512)
reader_task = asyncio.create_task(_guacd_reader_loop(stream, guacd_queue))
ws_lock = asyncio.Lock()
async def ws_send(text: str) -> None:
async with ws_lock:
await websocket.send_text(text)
async def forward_client_message(data: str) -> None:
for inst in iter_raw_instructions(data):
if is_internal_instruction(inst):
await ws_send(inst)
else:
writer.write(inst.encode("utf-8"))
await writer.drain()
try:
while True:
ws_recv_task = asyncio.create_task(websocket.receive_text())
guacd_recv_task = asyncio.create_task(guacd_queue.get())
done, pending = await asyncio.wait(
{ws_recv_task, guacd_recv_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
if ws_recv_task in done:
exc = ws_recv_task.exception()
if exc is not None:
if isinstance(exc, WebSocketDisconnect):
return
raise exc
await forward_client_message(ws_recv_task.result())
if guacd_recv_task in done:
batch = guacd_recv_task.result()
if batch is None:
return
await ws_send(batch.decode("utf-8", errors="replace"))
finally:
reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await reader_task
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()
with contextlib.suppress(Exception):
await writer.wait_closed()
await websocket.close(code=1011, reason=str(exc)[:120])
return
try:
await _relay_ws_guacd(websocket, stream, writer)
except WebSocketDisconnect:
pass
except Exception as exc:
logger.warning("RDP relay ended: %s", exc)
finally:
writer.close()
with contextlib.suppress(Exception):
await writer.wait_closed()
-6
View File
@@ -67,8 +67,6 @@ from server.api.websocket import router as websocket_router
from server.api.health import router as health_router
from server.api.assets import router as assets_router
from server.api.webssh import router as webssh_router
from server.api.rdp import router as rdp_router
from server.api.rdp_hosts import router as rdp_hosts_router
from server.api.sync_v2 import router as sync_v2_router
from server.api.files import router as files_router
from server.api.search import router as search_router
@@ -677,10 +675,6 @@ app.include_router(assets_router)
# Web SSH Terminal
app.include_router(webssh_router)
# Browser RDP (guacd tunnel + standalone hosts)
app.include_router(rdp_router)
app.include_router(rdp_hosts_router)
# Sync Engine v2 (Step 4)
app.include_router(sync_v2_router)
-151
View File
@@ -1,151 +0,0 @@
"""Server-level RDP settings (stored in ``servers.extra_attrs``)."""
from __future__ import annotations
from dataclasses import dataclass
from server.domain.models import Server
RDP_ENABLED_ATTR = "rdp_enabled"
RDP_PORT_ATTR = "rdp_port"
RDP_USERNAME_ATTR = "rdp_username"
RDP_PASSWORD_ATTR = "rdp_password"
DEFAULT_RDP_PORT = 3389
@dataclass(frozen=True)
class RdpConfig:
enabled: bool
port: int
username: str
password_set: bool
def _attrs(server: Server) -> dict:
raw = server.extra_attrs
return raw if isinstance(raw, dict) else {}
def normalize_rdp_port(value: object | None) -> int:
if value is None or value == "":
return DEFAULT_RDP_PORT
try:
port = int(value)
except (TypeError, ValueError):
return DEFAULT_RDP_PORT
if 1 <= port <= 65535:
return port
return DEFAULT_RDP_PORT
def get_rdp_config(server: Server) -> RdpConfig:
attrs = _attrs(server)
enabled = bool(attrs.get(RDP_ENABLED_ATTR))
port = normalize_rdp_port(attrs.get(RDP_PORT_ATTR))
username = str(attrs.get(RDP_USERNAME_ATTR) or "").strip()
password_set = bool(str(attrs.get(RDP_PASSWORD_ATTR) or "").strip())
return RdpConfig(
enabled=enabled,
port=port,
username=username,
password_set=password_set,
)
def get_rdp_password_plain(server: Server) -> str:
enc = str(_attrs(server).get(RDP_PASSWORD_ATTR) or "").strip()
if not enc:
return ""
from server.infrastructure.database.crypto import decrypt_value
try:
return decrypt_value(enc)
except Exception:
return ""
def merge_rdp_into_extra_attrs(
extra_attrs: dict | None,
*,
enabled: bool | None = None,
port: int | None = None,
username: str | None = None,
password_enc: str | None = None,
clear_password: bool = False,
) -> dict | None:
if (
enabled is None
and port is None
and username is None
and password_enc is None
and not clear_password
):
return extra_attrs
out = dict(extra_attrs or {})
if enabled is not None:
out[RDP_ENABLED_ATTR] = bool(enabled)
if port is not None:
out[RDP_PORT_ATTR] = normalize_rdp_port(port)
if username is not None:
out[RDP_USERNAME_ATTR] = username.strip()
if clear_password:
out.pop(RDP_PASSWORD_ATTR, None)
elif password_enc is not None:
out[RDP_PASSWORD_ATTR] = password_enc
return out
def apply_rdp_payload(data: dict, existing_extra: dict | None = None) -> None:
"""Pop RDP API fields from payload and merge into ``extra_attrs``."""
enabled = data.pop("rdp_enabled", None)
port = data.pop("rdp_port", None)
username = data.pop("rdp_username", None)
password = data.pop("rdp_password", None)
if enabled is None and port is None and username is None and password is None:
return
from server.infrastructure.database.crypto import encrypt_value
existing = dict(existing_extra or data.get("extra_attrs") or {})
password_enc = None
clear_password = False
if password is not None:
plain = str(password).strip()
if plain:
password_enc = encrypt_value(plain)
else:
clear_password = True
data["extra_attrs"] = merge_rdp_into_extra_attrs(
existing,
enabled=enabled,
port=port if port is not None else None,
username=username if username is not None else None,
password_enc=password_enc,
clear_password=clear_password,
)
def build_guacamole_connect_string(
hostname: str,
port: int,
username: str,
password: str,
) -> str:
"""Key=value string for guacamole-common-js ``client.connect()``."""
pairs = [
("hostname", hostname.strip()),
("port", str(normalize_rdp_port(port))),
("username", username.strip()),
("password", password),
("security", "any"),
("ignore-cert", "true"),
("disable-wallpaper", "true"),
("disable-theming", "true"),
("enable-font-smoothing", "true"),
("enable-full-window-drag", "false"),
("enable-desktop-composition", "false"),
("enable-menu-animations", "false"),
]
return "&".join(f"{k}={v}" for k, v in pairs if v != "")
-56
View File
@@ -1,56 +0,0 @@
"""Guacamole protocol encode/decode and internal opcode detection."""
import asyncio
import pytest
from server.infrastructure.guacamole.protocol import (
GuacdStream,
encode_instruction,
is_internal_instruction,
parse_instruction,
tunnel_open_instruction,
)
def test_encode_instruction():
assert encode_instruction("select", "rdp") == "6.select,3.rdp;"
def test_parse_instruction_roundtrip():
raw = encode_instruction("connect", "host", "3389").rstrip(";")
opcode, args = parse_instruction(raw)
assert opcode == "connect"
assert args == ["host", "3389"]
def test_tunnel_open_instruction():
msg = tunnel_open_instruction("abc-uuid")
assert is_internal_instruction(msg)
opcode, args = parse_instruction(msg.rstrip(";"))
assert opcode == ""
assert args == ["abc-uuid"]
def test_is_internal_ping():
ping = encode_instruction("", "ping", "12345")
assert is_internal_instruction(ping)
@pytest.mark.asyncio
async def test_guacd_stream_splits_tcp_chunks_on_semicolon():
class FakeReader:
def __init__(self, chunks: list[bytes]):
self._chunks = list(chunks)
async def read(self, n: int) -> bytes:
if not self._chunks:
return b""
return self._chunks.pop(0)
ins = encode_instruction("sync", "123").encode("utf-8")
reader = FakeReader([ins[:10], ins[10:]])
stream = GuacdStream(reader) # type: ignore[arg-type]
raw = await stream.read_raw_instruction()
assert raw == ins
assert raw.endswith(b";")
-55
View File
@@ -1,55 +0,0 @@
"""RDP browser (guacd) configuration helpers."""
from server.domain.models import Server
from server.infrastructure.database.crypto import encrypt_value
from server.utils.rdp_config import (
apply_rdp_payload,
build_guacamole_connect_string,
get_rdp_config,
get_rdp_password_plain,
)
def test_get_rdp_config_defaults():
server = Server(name="win", domain="10.0.0.1", port=22, username="root")
cfg = get_rdp_config(server)
assert cfg.enabled is False
assert cfg.port == 3389
assert cfg.username == ""
assert cfg.password_set is False
def test_apply_rdp_payload_merges_extra_attrs():
payload = {
"rdp_enabled": True,
"rdp_port": 3390,
"rdp_username": "Administrator",
"rdp_password": "Secret123",
}
apply_rdp_payload(payload, existing_extra={"files_elevation": "auto_sudo"})
assert payload["extra_attrs"]["rdp_enabled"] is True
assert payload["extra_attrs"]["rdp_port"] == 3390
assert payload["extra_attrs"]["rdp_username"] == "Administrator"
assert payload["extra_attrs"]["rdp_password"]
assert payload["extra_attrs"]["files_elevation"] == "auto_sudo"
assert "rdp_password" not in payload
def test_get_rdp_password_plain_roundtrip():
server = Server(
name="win",
domain="10.0.0.2",
port=22,
username="root",
extra_attrs={"rdp_password": encrypt_value("pw")},
)
assert get_rdp_password_plain(server) == "pw"
def test_build_guacamole_connect_string():
s = build_guacamole_connect_string("192.168.1.10", 3389, "Admin", "pw")
assert "hostname=192.168.1.10" in s
assert "port=3389" in s
assert "username=Admin" in s
assert "password=pw" in s
assert "ignore-cert=true" in s
-24
View File
@@ -1,24 +0,0 @@
"""Standalone RDP host model helpers."""
from server.domain.models import RdpHost
from server.infrastructure.database.crypto import decrypt_value, encrypt_value
from server.utils.rdp_config import normalize_rdp_port
def test_rdp_host_model_fields():
row = RdpHost(
name="win-bj",
host="10.0.0.5",
port=3389,
username="Administrator",
password=encrypt_value("Secret"),
)
assert row.name == "win-bj"
assert row.port == 3389
assert decrypt_value(row.password) == "Secret"
def test_normalize_rdp_port():
assert normalize_rdp_port(None) == 3389
assert normalize_rdp_port(3390) == 3390
assert normalize_rdp_port(99999) == 3389