diff --git a/docker-compose.yml b/docker-compose.yml index f760c7e1..8e7505ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 8e931555..e821dae8 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -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 diff --git a/docs/audit/2026-06-10-rdp-feature-removal.md b/docs/audit/2026-06-10-rdp-feature-removal.md new file mode 100644 index 00000000..c6fa0f5f --- /dev/null +++ b/docs/audit/2026-06-10-rdp-feature-removal.md @@ -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远程」 diff --git a/docs/changelog/2026-06-10-rdp-feature-removal.md b/docs/changelog/2026-06-10-rdp-feature-removal.md new file mode 100644 index 00000000..e49986ae --- /dev/null +++ b/docs/changelog/2026-06-10-rdp-feature-removal.md @@ -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 或回仪表盘(无路由)。 diff --git a/frontend/e2e/helpers.mjs b/frontend/e2e/helpers.mjs index 889b0ed7..6a484e72 100644 --- a/frontend/e2e/helpers.mjs +++ b/frontend/e2e/helpers.mjs @@ -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 }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae6b43ed..c592c75d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index e2bf5e4b..f6dd3af3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8d69a145..24f1d125 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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' }, diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 68ec3420..f3b82185 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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) { diff --git a/frontend/src/components/rdp/RdpHostFormDialog.vue b/frontend/src/components/rdp/RdpHostFormDialog.vue deleted file mode 100644 index 31a5032d..00000000 --- a/frontend/src/components/rdp/RdpHostFormDialog.vue +++ /dev/null @@ -1,115 +0,0 @@ - - - diff --git a/frontend/src/composables/rdp/useRdpHostList.ts b/frontend/src/composables/rdp/useRdpHostList.ts deleted file mode 100644 index 30b413ae..00000000 --- a/frontend/src/composables/rdp/useRdpHostList.ts +++ /dev/null @@ -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([]) - 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 { - saving.value = true - try { - const created = await http.post('/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 { - saving.value = true - try { - const body: Record = { - 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 { - 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, - } -} diff --git a/frontend/src/composables/rdp/useRdpSession.ts b/frontend/src/composables/rdp/useRdpSession.ts deleted file mode 100644 index ca766da1..00000000 --- a/frontend/src/composables/rdp/useRdpSession.ts +++ /dev/null @@ -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('idle') - const errorMessage = ref('') - const serverName = ref('') - const displayEl = shallowRef(null) - - let client: InstanceType | null = null - let tunnel: InstanceType | 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) { - 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(`/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, - } -} diff --git a/frontend/src/composables/useRoutePrefetch.ts b/frontend/src/composables/useRoutePrefetch.ts index 71d36866..4be38bdf 100644 --- a/frontend/src/composables/useRoutePrefetch.ts +++ b/frontend/src/composables/useRoutePrefetch.ts @@ -2,7 +2,6 @@ const ROUTE_CHUNKS: Record Promise> = { '/': () => 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'), diff --git a/frontend/src/pages/RdpPage.vue b/frontend/src/pages/RdpPage.vue deleted file mode 100644 index 8bca0bbc..00000000 --- a/frontend/src/pages/RdpPage.vue +++ /dev/null @@ -1,333 +0,0 @@ - - - - - diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 923f22a0..013f0eba 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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') }, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cfe31a8c..51480f60 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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 diff --git a/frontend/src/types/guacamole.d.ts b/frontend/src/types/guacamole.d.ts deleted file mode 100644 index 3c5fb2ff..00000000 --- a/frontend/src/types/guacamole.d.ts +++ /dev/null @@ -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 -} diff --git a/server/api/rdp.py b/server/api/rdp.py deleted file mode 100644 index b63d6062..00000000 --- a/server/api/rdp.py +++ /dev/null @@ -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 diff --git a/server/api/rdp_hosts.py b/server/api/rdp_hosts.py deleted file mode 100644 index 97225c75..00000000 --- a/server/api/rdp_hosts.py +++ /dev/null @@ -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, - } diff --git a/server/api/schemas.py b/server/api/schemas.py index 9ac9005f..1ee0065a 100644 --- a/server/api/schemas.py +++ b/server/api/schemas.py @@ -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: diff --git a/server/api/servers.py b/server/api/servers.py index 7f4e5a5e..376cde52 100644 --- a/server/api/servers.py +++ b/server/api/servers.py @@ -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, diff --git a/server/config.py b/server/config.py index 7dbee0b1..faf830ed 100644 --- a/server/config.py +++ b/server/config.py @@ -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 diff --git a/server/domain/models/__init__.py b/server/domain/models/__init__.py index 3690271d..555a6a5c 100644 --- a/server/domain/models/__init__.py +++ b/server/domain/models/__init__.py @@ -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) \ No newline at end of file diff --git a/server/infrastructure/database/migrations.py b/server/infrastructure/database/migrations.py index c3c4c8f2..dd13fed5 100644 --- a/server/infrastructure/database/migrations.py +++ b/server/infrastructure/database/migrations.py @@ -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: diff --git a/server/infrastructure/database/rdp_host_repo.py b/server/infrastructure/database/rdp_host_repo.py deleted file mode 100644 index 01307fa0..00000000 --- a/server/infrastructure/database/rdp_host_repo.py +++ /dev/null @@ -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 diff --git a/server/infrastructure/guacamole/__init__.py b/server/infrastructure/guacamole/__init__.py deleted file mode 100644 index 7c3af4a4..00000000 --- a/server/infrastructure/guacamole/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Guacamole protocol bridge (guacd).""" diff --git a/server/infrastructure/guacamole/protocol.py b/server/infrastructure/guacamole/protocol.py deleted file mode 100644 index af9111bb..00000000 --- a/server/infrastructure/guacamole/protocol.py +++ /dev/null @@ -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 diff --git a/server/infrastructure/guacamole/tunnel.py b/server/infrastructure/guacamole/tunnel.py deleted file mode 100644 index c76e899c..00000000 --- a/server/infrastructure/guacamole/tunnel.py +++ /dev/null @@ -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 diff --git a/server/infrastructure/guacamole/ws_tunnel.py b/server/infrastructure/guacamole/ws_tunnel.py deleted file mode 100644 index e4ddde0c..00000000 --- a/server/infrastructure/guacamole/ws_tunnel.py +++ /dev/null @@ -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() diff --git a/server/main.py b/server/main.py index 08fcb58e..a2549651 100644 --- a/server/main.py +++ b/server/main.py @@ -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) diff --git a/server/utils/rdp_config.py b/server/utils/rdp_config.py deleted file mode 100644 index 5e8c2d90..00000000 --- a/server/utils/rdp_config.py +++ /dev/null @@ -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 != "") diff --git a/tests/test_guacamole_protocol.py b/tests/test_guacamole_protocol.py deleted file mode 100644 index 625f0a8a..00000000 --- a/tests/test_guacamole_protocol.py +++ /dev/null @@ -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";") diff --git a/tests/test_rdp_config.py b/tests/test_rdp_config.py deleted file mode 100644 index 289fc13b..00000000 --- a/tests/test_rdp_config.py +++ /dev/null @@ -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 diff --git a/tests/test_rdp_hosts.py b/tests/test_rdp_hosts.py deleted file mode 100644 index ece407f2..00000000 --- a/tests/test_rdp_hosts.py +++ /dev/null @@ -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