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:
@@ -40,10 +40,6 @@ services:
|
||||
ports:
|
||||
- "${REDIS_PUBLISH_PORT:-6379}:6379"
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.5.5
|
||||
restart: unless-stopped
|
||||
|
||||
nexus:
|
||||
build:
|
||||
context: .
|
||||
@@ -54,8 +50,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
guacd:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${NEXUS_PUBLISH_PORT:-8600}:8600"
|
||||
environment:
|
||||
@@ -75,8 +69,6 @@ services:
|
||||
NEXUS_API_KEY: ${NEXUS_API_KEY:-}
|
||||
NEXUS_ENCRYPTION_KEY: ${NEXUS_ENCRYPTION_KEY:-}
|
||||
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:-http://localhost:8600}
|
||||
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
|
||||
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
|
||||
env_file:
|
||||
- path: docker/.env
|
||||
required: false
|
||||
|
||||
@@ -11,15 +11,7 @@
|
||||
name: nexus-prod
|
||||
|
||||
services:
|
||||
guacd:
|
||||
image: guacamole/guacd:1.5.5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nexus-internal
|
||||
|
||||
nexus:
|
||||
depends_on:
|
||||
- guacd
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.prod
|
||||
@@ -46,8 +38,6 @@ services:
|
||||
NEXUS_API_BASE_URL: ${NEXUS_API_BASE_URL:?set NEXUS_API_BASE_URL}
|
||||
NEXUS_DB_POOL_SIZE: ${NEXUS_DB_POOL_SIZE:-30}
|
||||
NEXUS_DB_MAX_OVERFLOW: ${NEXUS_DB_MAX_OVERFLOW:-20}
|
||||
NEXUS_GUACD_HOST: ${NEXUS_GUACD_HOST:-guacd}
|
||||
NEXUS_GUACD_PORT: ${NEXUS_GUACD_PORT:-4822}
|
||||
volumes:
|
||||
- nexus-state:/var/lib/nexus
|
||||
- nexus-web-data:/app/web/data
|
||||
|
||||
@@ -0,0 +1,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 或回仪表盘(无路由)。
|
||||
@@ -23,8 +23,42 @@ const SIDEBAR_LINKS = {
|
||||
'/audit': '审计日志',
|
||||
}
|
||||
|
||||
const E2E_ACCESS_TOKEN = process.env.NEXUS_E2E_ACCESS_TOKEN || ''
|
||||
|
||||
/** Mock refresh + profile so Pinia bootstraps with a pre-minted JWT (bypass login IP allowlist). */
|
||||
export async function loginWithAccessToken(page, accessToken = E2E_ACCESS_TOKEN) {
|
||||
if (!accessToken) {
|
||||
throw new Error('Set NEXUS_E2E_ACCESS_TOKEN for token-based E2E login')
|
||||
}
|
||||
await page.route('**/api/auth/refresh', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ access_token: accessToken }),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/auth/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 1,
|
||||
username: USER,
|
||||
totp_enabled: false,
|
||||
created_at: '2020-01-01T00:00:00Z',
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
|
||||
await expect(page.getByRole('button', { name: USER })).toBeVisible({ timeout: 15000 })
|
||||
}
|
||||
|
||||
/** Restore session from worker refresh cookie (avoids parallel UI logins / lockout). */
|
||||
export async function login(page) {
|
||||
if (E2E_ACCESS_TOKEN) {
|
||||
return loginWithAccessToken(page, E2E_ACCESS_TOKEN)
|
||||
}
|
||||
await page.goto(`${APP}#/`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/#\/login/, { timeout: 20000 })
|
||||
await expect(page.getByRole('button', { name: 'admin' })).toBeVisible({ timeout: 15000 })
|
||||
|
||||
Generated
-6
@@ -14,7 +14,6 @@
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"echarts": "^6.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30",
|
||||
@@ -1344,11 +1343,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/guacamole-common-js": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz",
|
||||
"integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg=="
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"echarts": "^6.1.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30",
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)."""
|
||||
@@ -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,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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 != "")
|
||||
@@ -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";")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user