update
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as ConnectionService from '../services/connection.service';
|
||||
import * as SshService from '../services/ssh.service';
|
||||
import * as GuacamoleService from '../services/guacamole.service'; // 导入 GuacamoleService
|
||||
import * as ImportExportService from '../services/import-export.service';
|
||||
import * as ConnectionRepository from '../repositories/connection.repository'; // +++ 导入 ConnectionRepository +++
|
||||
|
||||
@@ -269,10 +270,9 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
|
||||
}
|
||||
}
|
||||
};
|
||||
import axios from 'axios'; // +++ Import axios +++
|
||||
import axios from 'axios'; // axios 仍可能用于错误检查类型
|
||||
|
||||
// TODO: Make RDP backend URL configurable
|
||||
const RDP_BACKEND_API_BASE = process.env.RDP_BACKEND_API_BASE || 'http://localhost:9090';
|
||||
// RDP_BACKEND_API_BASE and VNC_BACKEND_API_BASE are now handled in GuacamoleService
|
||||
|
||||
/**
|
||||
* 获取 RDP 会话的 Guacamole 令牌 (通过调用 RDP 后端)
|
||||
@@ -320,66 +320,143 @@ export const getRdpSessionToken = async (req: Request, res: Response): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 准备调用 RDP 后端的参数
|
||||
const rdpApiParams = new URLSearchParams({
|
||||
hostname: connection.host,
|
||||
port: connection.port.toString(),
|
||||
username: connection.username,
|
||||
password: decryptedPassword, // 使用解密后的密码
|
||||
// Add other RDP parameters from connection object if needed by rdp backend
|
||||
security: (connection as any).rdp_security || 'any',
|
||||
ignoreCert: String((connection as any).rdp_ignore_cert ?? true),
|
||||
});
|
||||
const rdpTokenUrl = `${RDP_BACKEND_API_BASE}/api/get-token?${rdpApiParams.toString()}`;
|
||||
// 4. 调用 GuacamoleService 获取 RDP 令牌
|
||||
const guacamoleToken = await GuacamoleService.getRdpToken(connection, decryptedPassword);
|
||||
|
||||
console.log(`[Controller:getRdpSessionToken] Received Guacamole token via GuacamoleService for RDP connection ${connectionId}`);
|
||||
|
||||
console.log(`[Controller:getRdpSessionToken] Calling RDP backend API: ${RDP_BACKEND_API_BASE}/api/get-token?...`);
|
||||
// 5. 将 Guacamole 令牌返回给前端
|
||||
res.status(200).json({ token: guacamoleToken });
|
||||
|
||||
// 5. 调用 RDP 后端 API 获取 Guacamole 令牌
|
||||
const rdpResponse = await axios.get<{ token: string }>(rdpTokenUrl, {
|
||||
timeout: 10000 // 设置 10 秒超时
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message); // Log error message
|
||||
|
||||
if (rdpResponse.status !== 200 || !rdpResponse.data?.token) {
|
||||
console.error(`[Controller:getRdpSessionToken] RDP backend API call failed or returned invalid data. Status: ${rdpResponse.status}`, rdpResponse.data);
|
||||
throw new Error('从 RDP 后端获取令牌失败。');
|
||||
let statusCode = 500;
|
||||
let responseMessage = '获取 RDP 会话令牌时发生内部服务器错误。';
|
||||
|
||||
// 检查错误是否来自 GuacamoleService 或其内部的 axios 调用
|
||||
if (error.message.includes('调用 RDP 后端服务失败') || error.message.includes('从 RDP 后端获取令牌失败')) {
|
||||
responseMessage = error.message;
|
||||
// 尝试从错误消息中提取状态码,或者根据消息内容判断
|
||||
if (error.message.includes('(状态: 4')) statusCode = 400; // 例如 400, 401, 404
|
||||
else if (error.message.includes('(状态: 5')) statusCode = 502; // 例如 500, 502, 503, 504
|
||||
else statusCode = 503; // Service Unavailable or other specific error
|
||||
} else if (error.message.includes('RDP 连接需要使用密码认证') || error.message.includes('密码解密失败')) {
|
||||
responseMessage = error.message;
|
||||
statusCode = 400;
|
||||
} else if (error.message.includes('连接类型必须是 RDP')) {
|
||||
responseMessage = error.message;
|
||||
statusCode = 400;
|
||||
}
|
||||
// 可以保留对 axios.isAxiosError 的检查,以防 GuacamoleService 抛出未包装的 axios 错误
|
||||
// 但理想情况下,GuacamoleService 应该抛出更具体的错误。
|
||||
else if (axios.isAxiosError(error)) {
|
||||
responseMessage = '调用 RDP 后端服务时发生网络或请求错误。';
|
||||
if (error.response) {
|
||||
console.error('[Controller:getRdpSessionToken] RDP backend error response (unhandled by GuacamoleService):', error.response.data);
|
||||
responseMessage += ` (状态: ${error.response.status})`;
|
||||
statusCode = error.response.status >= 500 ? 502 : 400;
|
||||
} else if (error.request) {
|
||||
console.error('[Controller:getRdpSessionToken] No response from RDP backend (unhandled by GuacamoleService).');
|
||||
responseMessage += ' (无法连接或超时)';
|
||||
statusCode = 504;
|
||||
}
|
||||
} else if (error.message.includes('解密失败')) { // General decryption error from ConnectionService
|
||||
responseMessage = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
|
||||
}
|
||||
res.status(statusCode).json({ message: responseMessage });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 VNC 会话的 Guacamole 令牌 (通过调用 Guacamole 服务)
|
||||
* GET /api/v1/connections/:id/vnc-session
|
||||
*/
|
||||
export const getVncSessionToken = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const connectionId = parseInt(req.params.id, 10);
|
||||
if (isNaN(connectionId)) {
|
||||
res.status(400).json({ message: '无效的连接 ID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const guacamoleToken = rdpResponse.data.token;
|
||||
console.log(`[Controller:getRdpSessionToken] Received Guacamole token from RDP backend for connection ${connectionId}`);
|
||||
// 1. 获取连接信息和解密后的凭证
|
||||
const connectionData = await ConnectionService.getConnectionWithDecryptedCredentials(connectionId);
|
||||
|
||||
if (!connectionData) {
|
||||
res.status(404).json({ message: '连接未找到。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { connection, decryptedPassword } = connectionData;
|
||||
|
||||
// 2. 验证连接类型是否为 VNC
|
||||
if (connection.type !== 'VNC') {
|
||||
res.status(400).json({ message: '此连接类型不是 VNC。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 更新 last_connected_at
|
||||
try {
|
||||
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
||||
await ConnectionRepository.updateLastConnected(connectionId, currentTimeSeconds);
|
||||
console.log(`[Controller:getVncSessionToken] 已更新 VNC 连接 ${connectionId} 的 last_connected_at 为 ${currentTimeSeconds}`);
|
||||
} catch (updateError) {
|
||||
console.error(`[Controller:getVncSessionToken] 更新 VNC 连接 ${connectionId} 的 last_connected_at 时出错:`, updateError);
|
||||
}
|
||||
|
||||
// 4. 验证 VNC 连接是否使用密码认证 (VNC 通常总是需要密码)
|
||||
if (connection.auth_method !== 'password' || !decryptedPassword) {
|
||||
console.warn(`[Controller:getVncSessionToken] VNC connection ${connectionId} does not use password auth or password decryption failed.`);
|
||||
res.status(400).json({ message: 'VNC 连接需要使用密码认证,或密码解密失败。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 调用 GuacamoleService 获取 VNC 令牌
|
||||
const guacamoleToken = await GuacamoleService.getVncToken(connection, decryptedPassword);
|
||||
|
||||
console.log(`[Controller:getVncSessionToken] Received Guacamole token via GuacamoleService for VNC connection ${connectionId}`);
|
||||
|
||||
// 6. 将 Guacamole 令牌返回给前端
|
||||
res.status(200).json({ token: guacamoleToken });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error);
|
||||
console.error(`Controller: 获取 VNC 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message); // Log error message
|
||||
|
||||
let statusCode = 500;
|
||||
let message = '获取 RDP 会话令牌时发生内部服务器错误。';
|
||||
let responseMessage = '获取 VNC 会话令牌时发生内部服务器错误。';
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
message = '调用 RDP 后端服务时出错。';
|
||||
if (error.response) {
|
||||
// RDP 后端返回了错误响应
|
||||
console.error('[Controller:getRdpSessionToken] RDP backend error response:', error.response.data);
|
||||
message += ` (状态: ${error.response.status})`;
|
||||
statusCode = error.response.status >= 500 ? 502 : 400; // Bad Gateway or Bad Request
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应 (网络问题、超时)
|
||||
console.error('[Controller:getRdpSessionToken] No response from RDP backend.');
|
||||
message += ' (无法连接或超时)';
|
||||
statusCode = 504; // Gateway Timeout
|
||||
} else {
|
||||
// 设置请求时发生错误
|
||||
console.error('[Controller:getRdpSessionToken] Axios request setup error:', error.message);
|
||||
}
|
||||
} else if (error.message.includes('解密失败')) {
|
||||
message = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
|
||||
// 检查错误是否来自 GuacamoleService 或其内部的 axios 调用
|
||||
if (error.message.includes('调用 VNC 后端服务失败') || error.message.includes('从 VNC 后端获取令牌失败')) {
|
||||
responseMessage = error.message;
|
||||
if (error.message.includes('(状态: 4')) statusCode = 400;
|
||||
else if (error.message.includes('(状态: 5')) statusCode = 502;
|
||||
else statusCode = 503;
|
||||
} else if (error.message.includes('VNC 连接需要使用密码认证') || error.message.includes('密码解密失败')) {
|
||||
responseMessage = error.message;
|
||||
statusCode = 400;
|
||||
} else if (error.message.includes('连接类型必须是 VNC')) {
|
||||
responseMessage = error.message;
|
||||
statusCode = 400;
|
||||
}
|
||||
|
||||
res.status(statusCode).json({ message });
|
||||
// 可以保留对 axios.isAxiosError 的检查
|
||||
else if (axios.isAxiosError(error)) {
|
||||
responseMessage = '调用 VNC 后端服务时发生网络或请求错误。';
|
||||
if (error.response) {
|
||||
console.error('[Controller:getVncSessionToken] VNC backend error response (unhandled by GuacamoleService):', error.response.data);
|
||||
responseMessage += ` (状态: ${error.response.status})`;
|
||||
statusCode = error.response.status >= 500 ? 502 : 400;
|
||||
} else if (error.request) {
|
||||
console.error('[Controller:getVncSessionToken] No response from VNC backend (unhandled by GuacamoleService).');
|
||||
responseMessage += ' (无法连接或超时)';
|
||||
statusCode = 504;
|
||||
}
|
||||
} else if (error.message.includes('解密失败')) { // General decryption error from ConnectionService
|
||||
responseMessage = '获取 VNC 会话令牌时发生内部错误(凭证处理失败)。';
|
||||
}
|
||||
res.status(statusCode).json({ message: responseMessage });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 克隆连接 (POST /api/v1/connections/:id/clone)
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
exportConnections,
|
||||
importConnections,
|
||||
getRdpSessionToken, // Import the new controller function
|
||||
getVncSessionToken, // Import the VNC session token controller function
|
||||
cloneConnection, // +++ Import the clone controller function +++
|
||||
// updateConnectionTags, // No longer directly used by primary flow
|
||||
addTagToConnections // +++ Import the new controller function for adding tag to multiple connections +++
|
||||
@@ -83,6 +84,9 @@ router.post('/test-unsaved', testUnsavedConnection);
|
||||
// POST /api/v1/connections/:id/rdp-session - Get RDP session token via backend
|
||||
router.post('/:id/rdp-session', getRdpSessionToken);
|
||||
|
||||
// POST /api/v1/connections/:id/vnc-session - Get VNC session token
|
||||
router.post('/:id/vnc-session', getVncSessionToken);
|
||||
|
||||
// +++ POST /api/v1/connections/:id/clone - 克隆连接 +++
|
||||
router.post('/:id/clone', cloneConnection);
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export const createConnectionsTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NULL, -- 允许 name 为空
|
||||
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP')) DEFAULT 'SSH',
|
||||
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')) DEFAULT 'SSH',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
|
||||
interface ConnectionBase {
|
||||
id: number;
|
||||
name: string | null;
|
||||
type: 'SSH' | 'RDP'; // Add type field
|
||||
type: 'SSH' | 'RDP' | 'VNC'; // Add type field
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
|
||||
@@ -43,9 +43,9 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
console.log('[Service:createConnection] Received input:', JSON.stringify(input, null, 2)); // Log input
|
||||
// 1. 验证输入 (包含 type)
|
||||
// Convert type to uppercase for validation and consistency
|
||||
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined; // Ensure type safety
|
||||
if (!connectionType || !['SSH', 'RDP'].includes(connectionType)) {
|
||||
throw new Error('必须提供有效的连接类型 (SSH 或 RDP)。');
|
||||
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; // Ensure type safety
|
||||
if (!connectionType || !['SSH', 'RDP', 'VNC'].includes(connectionType)) {
|
||||
throw new Error('必须提供有效的连接类型 (SSH, RDP 或 VNC)。');
|
||||
}
|
||||
if (!input.host || !input.username) {
|
||||
throw new Error('缺少必要的连接信息 (host, username)。');
|
||||
@@ -70,6 +70,18 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
throw new Error('RDP 连接需要提供 password。');
|
||||
}
|
||||
// For RDP, we'll ignore auth_method, private_key, passphrase from input if provided
|
||||
} else if (connectionType === 'VNC') {
|
||||
if (!input.password) {
|
||||
throw new Error('VNC 连接需要提供 password。');
|
||||
}
|
||||
// For VNC, auth_method is implicitly 'password'.
|
||||
// ssh_key_id, private_key, passphrase are not applicable.
|
||||
if (input.auth_method && input.auth_method !== 'password') {
|
||||
throw new Error('VNC 连接的认证方式必须是 password。');
|
||||
}
|
||||
if (input.ssh_key_id || input.private_key) {
|
||||
throw new Error('VNC 连接不支持 SSH 密钥认证。');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 处理凭证和 ssh_key_id (根据 type)
|
||||
@@ -108,16 +120,27 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
|
||||
throw new Error('SSH 密钥认证方式内部错误:未提供 private_key 或 ssh_key_id。');
|
||||
}
|
||||
}
|
||||
} else { // RDP (connectionType is 'RDP')
|
||||
} else if (connectionType === 'RDP') { // RDP
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
// authMethodForDb remains 'password' for RDP to satisfy DB constraint
|
||||
// Ensure SSH specific fields are null for RDP
|
||||
// authMethodForDb remains 'password' for RDP
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyIdToSave = null;
|
||||
} else { // VNC
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
authMethodForDb = 'password'; // VNC always uses password auth
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyIdToSave = null;
|
||||
}
|
||||
|
||||
// 3. 准备仓库数据
|
||||
const defaultPort = input.type === 'RDP' ? 3389 : 22;
|
||||
let defaultPort = 22; // Default for SSH
|
||||
if (connectionType === 'RDP') {
|
||||
defaultPort = 3389;
|
||||
} else if (connectionType === 'VNC') {
|
||||
defaultPort = 5900; // Default VNC port
|
||||
}
|
||||
// +++ Explicitly type connectionData using the local alias +++
|
||||
const connectionData: ConnectionDataForRepo = {
|
||||
name: input.name || '',
|
||||
@@ -178,7 +201,7 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
||||
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData & { ssh_key_id?: number | null }, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
|
||||
let needsCredentialUpdate = false;
|
||||
// Determine the final type, converting input type to uppercase if provided
|
||||
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined || currentFullConnection.type;
|
||||
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined || currentFullConnection.type;
|
||||
|
||||
// 更新非凭证字段
|
||||
if (input.name !== undefined) dataToUpdate.name = input.name || '';
|
||||
@@ -280,20 +303,32 @@ if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes up
|
||||
dataToUpdate.encrypted_password = null;
|
||||
}
|
||||
}
|
||||
} else { // targetType is 'RDP'
|
||||
} else if (targetType === 'RDP') { // targetType is 'RDP'
|
||||
// RDP only uses password
|
||||
if (input.password !== undefined) { // Check if password was provided
|
||||
// Encrypt if password is not empty, otherwise set to null (to clear)
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// Ensure SSH specific fields are nullified if switching to RDP or updating RDP
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate) {
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
|
||||
dataToUpdate.auth_method = 'password'; // RDP uses password auth method in DB
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // RDP cannot use ssh_key_id
|
||||
}
|
||||
} else { // targetType is 'VNC'
|
||||
// VNC only uses password
|
||||
if (input.password !== undefined) { // Check if password was provided
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// Ensure SSH specific fields are nullified if switching to VNC or updating VNC
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
|
||||
dataToUpdate.auth_method = 'password'; // VNC uses password auth method in DB
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // VNC cannot use ssh_key_id
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果有更改,则更新连接记录
|
||||
|
||||
@@ -1 +1,110 @@
|
||||
// This file is intentionally left blank as Guacamole logic is handled by the separate rdp package.
|
||||
import axios from 'axios';
|
||||
import { ConnectionWithTags } from '../types/connection.types';
|
||||
|
||||
// RDP 后端服务的 Base URL,从环境变量读取,提供默认值
|
||||
const RDP_BACKEND_API_BASE = process.env.RDP_BACKEND_API_BASE || 'http://nexus-rdp:9090'; // 假设 RDP 服务名为 nexus-rdp
|
||||
|
||||
// VNC 后端服务的 Base URL,从环境变量读取,提供默认值
|
||||
const VNC_BACKEND_API_BASE = process.env.VNC_BACKEND_API_BASE || 'http://nexus-vnc:9091'; // 假设 VNC 服务名为 nexus-vnc,端口为 9091
|
||||
|
||||
/**
|
||||
* 从 RDP 后端服务获取 Guacamole 令牌
|
||||
* @param connection 连接对象
|
||||
* @param decryptedPassword 解密后的密码
|
||||
* @returns Guacamole 令牌
|
||||
*/
|
||||
export const getRdpToken = async (connection: ConnectionWithTags, decryptedPassword?: string): Promise<string> => {
|
||||
if (connection.type !== 'RDP') {
|
||||
throw new Error('连接类型必须是 RDP。');
|
||||
}
|
||||
if (connection.auth_method !== 'password' || !decryptedPassword) {
|
||||
console.warn(`[GuacamoleService:getRdpToken] RDP connection ${connection.id} does not use password auth or password decryption failed.`);
|
||||
throw new Error('RDP 连接需要使用密码认证,或密码解密失败。');
|
||||
}
|
||||
|
||||
const rdpApiParams = new URLSearchParams({
|
||||
hostname: connection.host,
|
||||
port: connection.port.toString(),
|
||||
username: connection.username,
|
||||
password: decryptedPassword,
|
||||
// 确保传递 RDP 特定的参数,如果存在的话
|
||||
security: (connection as any).rdp_security || 'any', // 从连接对象中获取,如果存在
|
||||
ignoreCert: String((connection as any).rdp_ignore_cert ?? true), // 从连接对象中获取,如果存在
|
||||
// 可以根据需要添加更多参数,例如 domain, width, height, dpi 等
|
||||
});
|
||||
const rdpTokenUrl = `${RDP_BACKEND_API_BASE}/api/get-token?${rdpApiParams.toString()}`;
|
||||
|
||||
console.log(`[GuacamoleService:getRdpToken] Calling RDP backend API: ${RDP_BACKEND_API_BASE}/api/get-token?... for connection ${connection.id}`);
|
||||
|
||||
try {
|
||||
const rdpResponse = await axios.get<{ token: string }>(rdpTokenUrl, {
|
||||
timeout: 10000 // 10 秒超时
|
||||
});
|
||||
|
||||
if (rdpResponse.status !== 200 || !rdpResponse.data?.token) {
|
||||
console.error(`[GuacamoleService:getRdpToken] RDP backend API call failed or returned invalid data. Status: ${rdpResponse.status}`, rdpResponse.data);
|
||||
throw new Error('从 RDP 后端获取令牌失败。');
|
||||
}
|
||||
console.log(`[GuacamoleService:getRdpToken] Received Guacamole token from RDP backend for connection ${connection.id}`);
|
||||
return rdpResponse.data.token;
|
||||
} catch (error: any) {
|
||||
console.error(`[GuacamoleService:getRdpToken] Error calling RDP backend for connection ${connection.id}:`, error.message);
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
throw new Error(`调用 RDP 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`);
|
||||
}
|
||||
throw new Error(`调用 RDP 后端服务时发生错误: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 VNC 后端服务获取 Guacamole 令牌
|
||||
* @param connection 连接对象
|
||||
* @param decryptedPassword 解密后的密码 (VNC 通常需要密码)
|
||||
* @returns Guacamole 令牌
|
||||
*/
|
||||
export const getVncToken = async (connection: ConnectionWithTags, decryptedPassword?: string): Promise<string> => {
|
||||
if (connection.type !== 'VNC') {
|
||||
throw new Error('连接类型必须是 VNC。');
|
||||
}
|
||||
// VNC 通常总是需要密码,并且 auth_method 应该被设置为 'password'
|
||||
if (connection.auth_method !== 'password' || !decryptedPassword) {
|
||||
console.warn(`[GuacamoleService:getVncToken] VNC connection ${connection.id} does not use password auth or password decryption failed.`);
|
||||
throw new Error('VNC 连接需要使用密码认证,或密码解密失败。');
|
||||
}
|
||||
|
||||
const vncApiParams = new URLSearchParams({
|
||||
hostname: connection.host,
|
||||
port: connection.port.toString(),
|
||||
password: decryptedPassword, // VNC 通常只需要密码
|
||||
// VNC 特有的参数可以根据 @nexus-terminal/vnc 的 API 进行添加
|
||||
// 例如: username (如果 VNC 服务器需要), colorDepth, etc.
|
||||
// username: connection.username, // 如果 VNC 服务支持用户名
|
||||
});
|
||||
|
||||
// 如果 VNC 服务也支持用户名,可以取消注释上面的 username 参数
|
||||
// 注意:标准的 VNC 协议主要通过密码进行认证,用户名不是标准部分,但某些实现可能支持。
|
||||
// 这里假设 @nexus-terminal/vnc 的 /api/get-vnc-token 接受这些参数。
|
||||
|
||||
const vncTokenUrl = `${VNC_BACKEND_API_BASE}/api/get-vnc-token?${vncApiParams.toString()}`;
|
||||
|
||||
console.log(`[GuacamoleService:getVncToken] Calling VNC backend API: ${VNC_BACKEND_API_BASE}/api/get-vnc-token?... for connection ${connection.id}`);
|
||||
|
||||
try {
|
||||
const vncResponse = await axios.get<{ token: string }>(vncTokenUrl, {
|
||||
timeout: 10000 // 10 秒超时
|
||||
});
|
||||
|
||||
if (vncResponse.status !== 200 || !vncResponse.data?.token) {
|
||||
console.error(`[GuacamoleService:getVncToken] VNC backend API call failed or returned invalid data. Status: ${vncResponse.status}`, vncResponse.data);
|
||||
throw new Error('从 VNC 后端获取令牌失败。');
|
||||
}
|
||||
console.log(`[GuacamoleService:getVncToken] Received Guacamole token from VNC backend for connection ${connection.id}`);
|
||||
return vncResponse.data.token;
|
||||
} catch (error: any) {
|
||||
console.error(`[GuacamoleService:getVncToken] Error calling VNC backend for connection ${connection.id}:`, error.message);
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
throw new Error(`调用 VNC 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`);
|
||||
}
|
||||
throw new Error(`调用 VNC 后端服务时发生错误: ${error.message}`);
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
export interface ConnectionBase {
|
||||
id: number;
|
||||
name: string | null;
|
||||
type: 'SSH' | 'RDP';
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -22,7 +22,7 @@ export interface ConnectionWithTags extends ConnectionBase {
|
||||
|
||||
export interface CreateConnectionInput {
|
||||
name?: string;
|
||||
type: 'SSH' | 'RDP';
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
host: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
@@ -39,7 +39,7 @@ notes?: string | null; // 新增备注字段
|
||||
|
||||
export interface UpdateConnectionInput {
|
||||
name?: string;
|
||||
type?: 'SSH' | 'RDP';
|
||||
type?: 'SSH' | 'RDP' | 'VNC';
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
@@ -57,7 +57,7 @@ notes?: string | null; // 新增备注字段
|
||||
export interface FullConnectionData {
|
||||
id: number;
|
||||
name: string | null;
|
||||
type: 'SSH' | 'RDP';
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
|
||||
@@ -30,7 +30,7 @@ const { isLoading: isSshKeyLoading, error: sshKeyStoreError } = storeToRefs(sshK
|
||||
|
||||
// 表单数据模型
|
||||
const initialFormData = {
|
||||
type: 'SSH' as 'SSH' | 'RDP', // Use uppercase to match ConnectionInfo
|
||||
type: 'SSH' as 'SSH' | 'RDP' | 'VNC', // Use uppercase to match ConnectionInfo
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
@@ -42,7 +42,8 @@ const initialFormData = {
|
||||
selected_ssh_key_id: null as number | null, // +++ Add field for selected key ID +++
|
||||
proxy_id: null as number | null,
|
||||
tag_ids: [] as number[], // 新增 tag_ids 字段
|
||||
notes: '', // 新增备注字段
|
||||
notes: '', // 新增备注字段
|
||||
vncPassword: '', // VNC specific password
|
||||
// Add RDP specific fields later if needed, e.g., domain
|
||||
};
|
||||
const formData = reactive({ ...initialFormData });
|
||||
@@ -79,7 +80,7 @@ watch(() => props.connectionToEdit, (newVal) => {
|
||||
formError.value = null; // 清除错误
|
||||
if (newVal) {
|
||||
// 编辑模式:填充表单,但不填充敏感信息
|
||||
formData.type = newVal.type; // Correctly set the type for editing
|
||||
formData.type = newVal.type as 'SSH' | 'RDP' | 'VNC'; // Correctly set the type for editing
|
||||
formData.name = newVal.name;
|
||||
formData.host = newVal.host;
|
||||
formData.port = newVal.port;
|
||||
@@ -90,23 +91,34 @@ formData.notes = newVal.notes ?? ''; // 填充备注
|
||||
formData.tag_ids = newVal.tag_ids ? [...newVal.tag_ids] : []; // 填充 tag_ids (深拷贝)
|
||||
|
||||
// +++ 填充 selected_ssh_key_id (如果认证方式是 key) +++
|
||||
if (newVal.auth_method === 'key') {
|
||||
if (newVal.type === 'SSH' && newVal.auth_method === 'key') {
|
||||
formData.selected_ssh_key_id = newVal.ssh_key_id ?? null;
|
||||
} else {
|
||||
formData.selected_ssh_key_id = null; // 清空,以防之前是 key
|
||||
formData.selected_ssh_key_id = null; // 清空
|
||||
}
|
||||
|
||||
// 清空敏感字段
|
||||
formData.password = ''; // For SSH/RDP
|
||||
formData.private_key = '';
|
||||
formData.passphrase = '';
|
||||
// formData.vncPassword is already handled by initialFormData or cleared if not VNC
|
||||
if (newVal.type !== 'VNC') {
|
||||
formData.vncPassword = '';
|
||||
} else {
|
||||
// If editing a VNC connection, we don't get vncPassword from backend directly.
|
||||
// User needs to re-enter if they want to change it.
|
||||
// For display, it's kept empty.
|
||||
formData.vncPassword = '';
|
||||
}
|
||||
|
||||
// 清空敏感字段 (密码和直接输入的密钥)
|
||||
formData.password = '';
|
||||
formData.private_key = ''; // 即使是 key 认证,编辑时也不显示旧的直接输入密钥
|
||||
formData.passphrase = ''; // 同上
|
||||
|
||||
} else {
|
||||
// 添加模式:重置表单
|
||||
Object.assign(formData, initialFormData);
|
||||
formData.tag_ids = []; // 确保 tag_ids 也被重置为空数组
|
||||
formData.selected_ssh_key_id = null; // 确保添加模式下也重置
|
||||
formData.notes = ''; // 重置备注
|
||||
formData.notes = ''; // 重置备注
|
||||
formData.vncPassword = ''; // 重置VNC密码
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@@ -120,15 +132,17 @@ onMounted(() => {
|
||||
// 监听连接类型变化,动态调整默认端口
|
||||
watch(() => formData.type, (newType) => {
|
||||
// Use uppercase for comparison
|
||||
if (newType === 'RDP' && formData.port === 22) {
|
||||
formData.port = 3389; // RDP 默认端口
|
||||
} else if (newType === 'SSH' && formData.port === 3389) {
|
||||
formData.port = 22; // SSH 默认端口
|
||||
}
|
||||
// 重置或调整认证方式等逻辑可以在这里添加
|
||||
if (newType === 'RDP') {
|
||||
// RDP 通常只用密码,可以强制或隐藏 auth_method
|
||||
// formData.auth_method = 'password'; // Example: Force password for RDP
|
||||
if (formData.port === 22 || formData.port === 5900 || formData.port === 5901) formData.port = 3389; // RDP 默认端口
|
||||
formData.auth_method = 'password'; // RDP uses password
|
||||
formData.selected_ssh_key_id = null; // Clear SSH key selection
|
||||
} else if (newType === 'SSH') {
|
||||
if (formData.port === 3389 || formData.port === 5900 || formData.port === 5901) formData.port = 22; // SSH 默认端口
|
||||
// auth_method will be handled by its own select
|
||||
} else if (newType === 'VNC') {
|
||||
if (formData.port === 22 || formData.port === 3389) formData.port = 5900; // VNC 默认端口 (e.g., 5900 or 5901)
|
||||
formData.auth_method = 'password'; // VNC uses password, hide auth_method selector
|
||||
formData.selected_ssh_key_id = null; // Clear SSH key selection
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,11 +204,18 @@ const handleSubmit = async () => {
|
||||
// RDP Validation
|
||||
// 1. 添加模式下,密码是必填的
|
||||
if (!isEditMode.value && !formData.password) {
|
||||
formError.value = t('connections.form.errorPasswordRequired'); // 可以复用密码必填的翻译
|
||||
formError.value = t('connections.form.errorPasswordRequired');
|
||||
return;
|
||||
}
|
||||
// 2. 编辑模式下,密码可以不填(表示不修改),除非是从非 RDP 类型切换过来(这个逻辑比较复杂,暂时简化为密码非必填)
|
||||
// 如果需要更严格的验证(例如从 SSH 编辑为 RDP 时强制要求输入密码),可以在这里添加
|
||||
// 2. 编辑模式下,密码可以不填(表示不修改)
|
||||
} else if (formData.type === 'VNC') {
|
||||
// VNC Validation
|
||||
// 1. 添加模式下,VNC密码是必填的
|
||||
if (!isEditMode.value && !formData.vncPassword) {
|
||||
formError.value = t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。'); // Add new translation key
|
||||
return;
|
||||
}
|
||||
// 2. 编辑模式下,VNC密码可以不填(表示不修改)
|
||||
}
|
||||
// --- 验证逻辑结束 ---
|
||||
|
||||
@@ -256,6 +277,21 @@ notes: formData.notes, // 添加备注
|
||||
delete dataToSend.auth_method;
|
||||
delete dataToSend.private_key;
|
||||
delete dataToSend.passphrase;
|
||||
delete dataToSend.vncPassword; // Ensure VNC password field for form doesn't go if RDP
|
||||
} else if (formData.type === 'VNC') {
|
||||
// VNC data population
|
||||
if (formData.vncPassword) {
|
||||
dataToSend.password = formData.vncPassword; // Backend expects VNC password in 'password' field
|
||||
} else if (isEditMode.value && formData.vncPassword === '') {
|
||||
// Editing VNC, empty password means don't change
|
||||
}
|
||||
// VNC does not use SSH specific fields
|
||||
delete dataToSend.auth_method;
|
||||
delete dataToSend.private_key;
|
||||
delete dataToSend.passphrase;
|
||||
delete dataToSend.ssh_key_id;
|
||||
// formData.vncPassword is used for the form, but backend might expect it as 'password'
|
||||
// So, we don't send 'vncPassword' itself to backend.
|
||||
}
|
||||
|
||||
|
||||
@@ -424,6 +460,7 @@ const testButtonText = computed(() => {
|
||||
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
|
||||
<option value="SSH">{{ t('connections.form.typeSsh', 'SSH') }}</option>
|
||||
<option value="RDP">{{ t('connections.form.typeRdp', 'RDP') }}</option>
|
||||
<option value="VNC">{{ t('connections.form.typeVnc', 'VNC') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Host and Port Row -->
|
||||
@@ -498,13 +535,22 @@ const testButtonText = computed(() => {
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- VNC Specific Auth (Password only) -->
|
||||
<template v-if="formData.type === 'VNC'">
|
||||
<div>
|
||||
<label for="conn-password-vnc" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.vncPassword', 'VNC 密码') }}</label>
|
||||
<input type="password" id="conn-password-vnc" v-model="formData.vncPassword" :required="!isEditMode" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options Section -->
|
||||
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
|
||||
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionAdvanced', '高级选项') }}</h4>
|
||||
<div v-if="formData.type !== 'RDP'"> <!-- Proxy Select - Hide for RDP -->
|
||||
<div v-if="formData.type === 'SSH'"> <!-- Proxy Select - Show only for SSH -->
|
||||
<label for="conn-proxy" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.proxy') }} ({{ t('connections.form.optional') }})</label>
|
||||
<select id="conn-proxy" v-model="formData.proxy_id"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
|
||||
@@ -551,7 +597,6 @@ const testButtonText = computed(() => {
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-between items-center pt-5 mt-6 flex-shrink-0">
|
||||
<!-- Test Area (Only show for SSH) -->
|
||||
<!-- Use uppercase for comparison -->
|
||||
<div v-if="formData.type === 'SSH'" class="flex flex-col items-start gap-1">
|
||||
<div class="flex items-center gap-2"> <!-- Button and Icon -->
|
||||
<button type="button" @click="handleTestConnection" :disabled="isLoading || testStatus === 'testing'"
|
||||
@@ -583,7 +628,7 @@ const testButtonText = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Placeholder for alignment when test button is hidden -->
|
||||
<div v-else class="flex-1"></div>
|
||||
<div v-else class="flex-1"></div> <!-- This div ensures the main action buttons are pushed to the right when test area is hidden -->
|
||||
<div class="flex space-x-3"> <!-- Main Actions -->
|
||||
<button type="submit" @click="handleSubmit" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
|
||||
|
||||
@@ -182,7 +182,7 @@ const handleDelete = async (conn: ConnectionInfo) => {
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr v-for="conn in groupConnections" :key="conn.id" class="hover:bg-hover transition-colors duration-150">
|
||||
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap flex items-center">
|
||||
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<i :class="['fas', conn.type === 'RDP' || conn.type === 'VNC' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<span>{{ conn.name }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.host }}</td>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useConnectionsStore } from '../stores/connections.store'; // +++ Import connections store +++
|
||||
// @ts-ignore - guacamole-common-js 缺少官方类型定义
|
||||
import Guacamole from 'guacamole-common-js';
|
||||
import apiClient from '../utils/apiClient';
|
||||
@@ -36,69 +37,100 @@ const MIN_MODAL_HEIGHT = 768;
|
||||
|
||||
// Dynamically construct WebSocket URL based on environment
|
||||
let backendBaseUrl: string;
|
||||
const LOCAL_BACKEND_URL = 'ws://localhost:3001'
|
||||
const LOCAL_BACKEND_URL = 'ws://localhost:3001'; // For RDP proxy via main backend
|
||||
|
||||
// Determine WebSocket URL based on hostname
|
||||
// Determine WebSocket URL based on hostname for RDP
|
||||
if (window.location.hostname === 'localhost') {
|
||||
backendBaseUrl = LOCAL_BACKEND_URL;
|
||||
console.log(`[RDP 模态框] 使用 localhost WebSocket 基础 URL: ${backendBaseUrl}`);
|
||||
console.log(`[RemoteDesktopModal] Using localhost RDP WebSocket base URL: ${backendBaseUrl}`);
|
||||
} else {
|
||||
// 备选方案: 根据当前 window.location 为生产环境或其他环境构建 URL
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHostAndPort = window.location.host;
|
||||
backendBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws`;
|
||||
console.log(`[RDP 模态框] 使用生产环境 WebSocket 基础 URL (来自 window.location): ${backendBaseUrl}`);
|
||||
backendBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws`; // Assuming RDP proxy is at /ws path
|
||||
console.log(`[RemoteDesktopModal] Using production RDP WebSocket base URL (from window.location): ${backendBaseUrl}`);
|
||||
}
|
||||
|
||||
const connectRdp = async () => {
|
||||
// NEW: VNC WebSocket URL determination
|
||||
let vncWsBaseUrl: string;
|
||||
const VNC_WS_PORT_FROM_ENV = import.meta.env.VITE_VNC_WS_PORT || '8082'; // Get from env or default
|
||||
|
||||
if (window.location.hostname === 'localhost') {
|
||||
vncWsBaseUrl = `ws://localhost:${VNC_WS_PORT_FROM_ENV}`;
|
||||
console.log(`[RemoteDesktopModal] Using localhost VNC WebSocket base URL: ${vncWsBaseUrl}`);
|
||||
} else {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// Assuming VNC proxy runs on the same host but different port (or path if configured)
|
||||
vncWsBaseUrl = `${wsProtocol}//${window.location.hostname}:${VNC_WS_PORT_FROM_ENV}`;
|
||||
console.log(`[RemoteDesktopModal] Using production VNC WebSocket base URL: ${vncWsBaseUrl}`);
|
||||
}
|
||||
|
||||
const handleConnection = async () => {
|
||||
if (!props.connection || !rdpDisplayRef.value) {
|
||||
statusMessage.value = t('remoteDesktopModal.errors.missingInfo');
|
||||
connectionStatus.value = 'error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous display and disconnect
|
||||
while (rdpDisplayRef.value.firstChild) {
|
||||
rdpDisplayRef.value.removeChild(rdpDisplayRef.value.firstChild);
|
||||
}
|
||||
disconnectRdp();
|
||||
disconnectGuacamole(); // Renamed from disconnectRdp
|
||||
|
||||
connectionStatus.value = 'connecting';
|
||||
statusMessage.value = t('remoteDesktopModal.status.fetchingToken');
|
||||
|
||||
try {
|
||||
const apiUrl = `connections/${props.connection.id}/rdp-session`;
|
||||
let token: string | null = null;
|
||||
let tunnelUrl: string = '';
|
||||
const connectionsStore = useConnectionsStore();
|
||||
|
||||
const response = await apiClient.post<{ token: string }>(apiUrl);
|
||||
if (props.connection.type === 'RDP') {
|
||||
const apiUrl = `connections/${props.connection.id}/rdp-session`;
|
||||
const response = await apiClient.post<{ token: string }>(apiUrl);
|
||||
token = response.data?.token;
|
||||
if (!token) {
|
||||
throw new Error('RDP Token not found in API response');
|
||||
}
|
||||
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
|
||||
|
||||
const token = response.data?.token;
|
||||
if (!token) {
|
||||
throw new Error('Token not found in API response');
|
||||
}
|
||||
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
|
||||
await nextTick();
|
||||
let widthToSend = 800;
|
||||
let heightToSend = 600;
|
||||
const dpiToSend = 96;
|
||||
|
||||
// DOM 更新后获取 RDP 容器尺寸
|
||||
await nextTick();
|
||||
|
||||
let widthToSend = 800; // 默认/备用宽度
|
||||
let heightToSend = 600; // 默认/备用高度
|
||||
const dpiToSend = 96;
|
||||
|
||||
if (rdpContainerRef.value) {
|
||||
// 使用 clientWidth/clientHeight,因为它们代表可用于内容的内部尺寸
|
||||
if (rdpContainerRef.value) {
|
||||
widthToSend = rdpContainerRef.value.clientWidth;
|
||||
heightToSend = rdpContainerRef.value.clientHeight - 1; // 根据反馈减去 1
|
||||
// 确保最小尺寸,必要时根据后端要求进行调整
|
||||
heightToSend = rdpContainerRef.value.clientHeight - 1;
|
||||
widthToSend = Math.max(100, widthToSend);
|
||||
heightToSend = Math.max(100, heightToSend);
|
||||
console.log(`计算出的 RDP 尺寸: ${widthToSend}x${heightToSend}`);
|
||||
}
|
||||
tunnelUrl = `${backendBaseUrl}/rdp-proxy?token=${encodeURIComponent(token)}&width=${widthToSend}&height=${heightToSend}&dpi=${dpiToSend}`;
|
||||
console.log(`[RemoteDesktopModal] Connecting to RDP tunnel: ${tunnelUrl}`);
|
||||
|
||||
} else if (props.connection.type === 'VNC') {
|
||||
token = await connectionsStore.getVncSessionToken(props.connection.id);
|
||||
if (!token) {
|
||||
throw new Error('VNC Token not found from store action');
|
||||
}
|
||||
statusMessage.value = t('remoteDesktopModal.status.connectingWs'); // Generic message
|
||||
tunnelUrl = `${vncWsBaseUrl}?token=${encodeURIComponent(token)}`;
|
||||
// Optional: Add width/height if VNC proxy needs them, though Guacamole usually handles this post-connection.
|
||||
// await nextTick();
|
||||
// let widthToSend = 800;
|
||||
// let heightToSend = 600;
|
||||
// if (rdpContainerRef.value) {
|
||||
// widthToSend = rdpContainerRef.value.clientWidth;
|
||||
// heightToSend = rdpContainerRef.value.clientHeight - 1;
|
||||
// widthToSend = Math.max(100, widthToSend);
|
||||
// heightToSend = Math.max(100, heightToSend);
|
||||
// tunnelUrl += `&width=${widthToSend}&height=${heightToSend}`;
|
||||
// }
|
||||
console.log(`[RemoteDesktopModal] Connecting to VNC tunnel: ${tunnelUrl}`);
|
||||
} else {
|
||||
console.warn("RDP 容器引用不可用,无法获取尺寸。使用默认值。");
|
||||
// 考虑设置错误状态或通知用户
|
||||
throw new Error(`Unsupported connection type: ${props.connection.type}`);
|
||||
}
|
||||
|
||||
// 使用确定的基础 URL 构建后端代理端点的 URL
|
||||
const tunnelUrl = `${backendBaseUrl}/rdp-proxy?token=${encodeURIComponent(token)}&width=${widthToSend}&height=${heightToSend}&dpi=${dpiToSend}`;
|
||||
console.log(`[RDP 模态框] 连接到隧道: ${tunnelUrl}`); // 记录最终 URL
|
||||
// @ts-ignore
|
||||
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
||||
|
||||
@@ -107,82 +139,77 @@ const connectRdp = async () => {
|
||||
const errorCode = status.code || 'N/A';
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.tunnelError')} (${errorCode}): ${errorMessage}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
guacClient.value = new Guacamole.Client(tunnel);
|
||||
// 添加此行以启用 keep-alive (每 3 秒发送 NOP)
|
||||
guacClient.value.keepAliveFrequency = 3000; // 毫秒
|
||||
guacClient.value.keepAliveFrequency = 3000;
|
||||
|
||||
rdpDisplayRef.value.appendChild(guacClient.value.getDisplay().getElement());
|
||||
|
||||
guacClient.value.onstatechange = (state: number) => {
|
||||
let currentStatus = '';
|
||||
let i18nKeyPart = 'unknownState';
|
||||
|
||||
switch (state) {
|
||||
case 0:
|
||||
statusMessage.value = t('remoteDesktopModal.status.idle');
|
||||
connectionStatus.value = 'disconnected';
|
||||
case 0: // IDLE
|
||||
i18nKeyPart = 'idle';
|
||||
currentStatus = 'disconnected';
|
||||
break;
|
||||
case 1:
|
||||
statusMessage.value = t('remoteDesktopModal.status.connectingRdp');
|
||||
connectionStatus.value = 'connecting';
|
||||
case 1: // CONNECTING
|
||||
i18nKeyPart = props.connection?.type === 'VNC' ? 'connectingVnc' : 'connectingRdp';
|
||||
currentStatus = 'connecting';
|
||||
break;
|
||||
case 2:
|
||||
statusMessage.value = t('remoteDesktopModal.status.waiting');
|
||||
connectionStatus.value = 'connecting';
|
||||
case 2: // WAITING
|
||||
i18nKeyPart = 'waiting';
|
||||
currentStatus = 'connecting';
|
||||
break;
|
||||
case 3:
|
||||
statusMessage.value = t('remoteDesktopModal.status.connected');
|
||||
connectionStatus.value = 'connected';
|
||||
case 3: // CONNECTED
|
||||
i18nKeyPart = 'connected';
|
||||
currentStatus = 'connected';
|
||||
setupInputListeners();
|
||||
// 连接成功后,尝试将焦点设置到 RDP 显示区域
|
||||
nextTick(() => {
|
||||
const displayEl = guacClient.value?.getDisplay()?.getElement();
|
||||
if (displayEl && typeof displayEl.focus === 'function') {
|
||||
displayEl.focus();
|
||||
console.log('[RDP Modal] Focused RDP display after connection.');
|
||||
} else {
|
||||
console.warn('[RDP Modal] Could not focus RDP display after connection.');
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setTimeout(() => { // z-index fix for canvas
|
||||
nextTick(() => {
|
||||
if (rdpDisplayRef.value && guacClient.value) {
|
||||
const canvases = rdpDisplayRef.value.querySelectorAll('canvas');
|
||||
canvases.forEach((canvas) => {
|
||||
canvas.style.zIndex = '999';
|
||||
});
|
||||
canvases.forEach((canvas) => { canvas.style.zIndex = '999'; });
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
break;
|
||||
case 4:
|
||||
statusMessage.value = t('remoteDesktopModal.status.disconnecting');
|
||||
connectionStatus.value = 'disconnected';
|
||||
case 4: // DISCONNECTING
|
||||
i18nKeyPart = 'disconnecting';
|
||||
currentStatus = 'disconnected'; // Or 'disconnecting'
|
||||
break;
|
||||
case 5:
|
||||
statusMessage.value = t('remoteDesktopModal.status.disconnected');
|
||||
connectionStatus.value = 'disconnected';
|
||||
case 5: // DISCONNECTED
|
||||
i18nKeyPart = 'disconnected';
|
||||
currentStatus = 'disconnected';
|
||||
break;
|
||||
default:
|
||||
statusMessage.value = `${t('remoteDesktopModal.status.unknownState')}: ${state}`;
|
||||
}
|
||||
statusMessage.value = t(`remoteDesktopModal.status.${i18nKeyPart}`, { state });
|
||||
if (currentStatus) connectionStatus.value = currentStatus as 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
};
|
||||
|
||||
guacClient.value.onerror = (status: any) => {
|
||||
const errorMessage = status.message || 'Unknown client error';
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.clientError')}: ${errorMessage}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
};
|
||||
|
||||
guacClient.value.connect(''); // 保留 '' 的更改
|
||||
guacClient.value.connect('');
|
||||
|
||||
} catch (error: any) {
|
||||
statusMessage.value = `${t('remoteDesktopModal.errors.connectionFailed')}: ${error.response?.data?.message || error.message || String(error)}`;
|
||||
connectionStatus.value = 'error';
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,7 +342,7 @@ const enableRdpKeyboard = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const disconnectRdp = () => {
|
||||
const disconnectGuacamole = () => {
|
||||
removeInputListeners();
|
||||
isKeyboardDisabledForInput.value = false; // 确保状态重置
|
||||
if (guacClient.value) {
|
||||
@@ -335,7 +362,7 @@ const disconnectRdp = () => {
|
||||
|
||||
|
||||
const closeModal = () => {
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
@@ -411,7 +438,7 @@ onMounted(() => {
|
||||
|
||||
if (props.connection) {
|
||||
nextTick(async () => {
|
||||
await connectRdp(); // 使用初始尺寸连接
|
||||
await handleConnection(); // 使用初始尺寸连接
|
||||
// 不再需要设置 observer
|
||||
});
|
||||
} else {
|
||||
@@ -421,17 +448,17 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnectRdp(); // 这里已经调用了 removeInputListeners
|
||||
disconnectGuacamole(); // 这里已经调用了 removeInputListeners
|
||||
});
|
||||
|
||||
watch(() => props.connection, (newConnection, oldConnection) => {
|
||||
if (newConnection && newConnection.id !== oldConnection?.id) {
|
||||
nextTick(async () => {
|
||||
await connectRdp(); // 使用初始尺寸连接
|
||||
await handleConnection(); // 使用初始尺寸连接
|
||||
// 不再需要设置 observer
|
||||
});
|
||||
} else if (!newConnection) {
|
||||
disconnectRdp();
|
||||
disconnectGuacamole();
|
||||
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
|
||||
connectionStatus.value = 'error';
|
||||
}
|
||||
@@ -491,7 +518,7 @@ const computedModalStyle = computed(() => {
|
||||
<i v-else class="fas fa-exclamation-triangle fa-2x mb-3 text-red-400"></i>
|
||||
<p class="text-sm">{{ statusMessage }}</p>
|
||||
<button v-if="connectionStatus === 'error'"
|
||||
@click="() => connectRdp()"
|
||||
@click="() => handleConnection()"
|
||||
class="mt-4 px-3 py-1 bg-primary text-white rounded text-xs hover:bg-primary-dark">
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
@@ -523,12 +550,12 @@ const computedModalStyle = computed(() => {
|
||||
/>
|
||||
<!-- 添加重新连接按钮 -->
|
||||
<button
|
||||
@click="connectRdp"
|
||||
:disabled="connectionStatus === 'connecting'"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
|
||||
:title="t('remoteDesktopModal.reconnectTooltip')"
|
||||
@click="handleConnection"
|
||||
:disabled="connectionStatus === 'connecting'"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
|
||||
:title="t('remoteDesktopModal.reconnectTooltip')"
|
||||
>
|
||||
{{ t('common.reconnect') }}
|
||||
{{ t('common.reconnect') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"password": "Password:",
|
||||
"privateKey": "Private Key:",
|
||||
"passphrase": "Passphrase:",
|
||||
"vncPassword": "VNC Password",
|
||||
"optional": "Optional",
|
||||
"confirm": "Confirm Add",
|
||||
"adding": "Adding...",
|
||||
@@ -150,6 +151,7 @@
|
||||
"errorPrivateKeyRequired": "Private key is required for key authentication.",
|
||||
"errorPasswordRequiredOnSwitch": "Password is required when switching to password authentication.",
|
||||
"errorPrivateKeyRequiredOnSwitch": "Private key is required when switching to key authentication.",
|
||||
"errorVncPasswordRequired": "VNC password is required.",
|
||||
"errorPort": "Port must be between 1 and 65535.",
|
||||
"errorAdd": "Failed to add connection: {error}",
|
||||
"titleEdit": "Edit Connection",
|
||||
@@ -165,6 +167,7 @@
|
||||
"connectionType": "Connection Type:",
|
||||
"typeSsh": "SSH",
|
||||
"typeRdp": "RDP",
|
||||
"typeVnc": "VNC",
|
||||
"sectionBasic": "Basic Information",
|
||||
"sectionAuth": "Authentication",
|
||||
"sectionAdvanced": "Advanced Options",
|
||||
@@ -897,6 +900,7 @@
|
||||
"connectingWs": "Connecting WebSocket...",
|
||||
"idle": "Idle",
|
||||
"connectingRdp": "Connecting Remote Desktop...",
|
||||
"connectingVnc": "Connecting VNC...",
|
||||
"waiting": "Waiting for server response...",
|
||||
"connected": "Connected",
|
||||
"disconnecting": "Disconnecting...",
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"password": "密码:",
|
||||
"privateKey": "私钥:",
|
||||
"passphrase": "私钥密码:",
|
||||
"vncPassword": "VNC 密码:",
|
||||
"optional": "可选",
|
||||
"confirm": "确认添加",
|
||||
"adding": "正在添加...",
|
||||
@@ -150,6 +151,7 @@
|
||||
"errorPrivateKeyRequired": "使用密钥认证时,私钥为必填项。",
|
||||
"errorPasswordRequiredOnSwitch": "切换到密码认证时,密码为必填项。",
|
||||
"errorPrivateKeyRequiredOnSwitch": "切换到密钥认证时,私钥为必填项。",
|
||||
"errorVncPasswordRequired": "VNC 密码是必填项。",
|
||||
"errorPort": "端口号必须在 1 到 65535 之间。",
|
||||
"errorAdd": "添加连接失败: {error}",
|
||||
"titleEdit": "编辑连接",
|
||||
@@ -165,6 +167,7 @@
|
||||
"connectionType": "连接类型",
|
||||
"typeSsh": "SSH",
|
||||
"typeRdp": "RDP",
|
||||
"typeVnc": "VNC",
|
||||
"sectionBasic": "基本信息",
|
||||
"sectionAuth": "认证信息",
|
||||
"sectionAdvanced": "高级选项",
|
||||
@@ -900,8 +903,9 @@
|
||||
"connectingWs": "正在连接 WebSocket...",
|
||||
"idle": "空闲",
|
||||
"connectingRdp": "正在连接远程桌面...",
|
||||
"connectingVnc": "正在连接 VNC...",
|
||||
"waiting": "等待服务器响应...",
|
||||
"connecting": "连接中...",
|
||||
"connecting": "连接中...",
|
||||
"error": "错误",
|
||||
"connected": "已连接",
|
||||
"disconnecting": "正在断开连接...",
|
||||
|
||||
@@ -5,7 +5,7 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
||||
export interface ConnectionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'SSH' | 'RDP'; // Use uppercase to match backend data
|
||||
type: 'SSH' | 'RDP' | 'VNC'; // Use uppercase to match backend data
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -17,6 +17,7 @@ export interface ConnectionInfo {
|
||||
updated_at: number;
|
||||
last_connected_at: number | null;
|
||||
notes?: string | null; // 新增备注字段
|
||||
vncPassword?: string; // VNC specific password
|
||||
}
|
||||
|
||||
// 定义 Store State 的接口
|
||||
@@ -87,14 +88,15 @@ export const useConnectionsStore = defineStore('connections', {
|
||||
// 更新参数类型以接受新的认证字段
|
||||
async addConnection(newConnectionData: {
|
||||
name: string;
|
||||
type: 'SSH' | 'RDP'; // Use uppercase
|
||||
type: 'SSH' | 'RDP' | 'VNC'; // Use uppercase
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password' | 'key';
|
||||
password?: string;
|
||||
private_key?: string;
|
||||
passphrase?: string;
|
||||
auth_method: 'password' | 'key'; // SSH specific
|
||||
password?: string; // SSH password or general password
|
||||
private_key?: string; // SSH specific
|
||||
passphrase?: string; // SSH specific
|
||||
vncPassword?: string; // VNC specific password
|
||||
proxy_id?: number | null;
|
||||
tag_ids?: number[]; // 新增:允许传入 tag_ids
|
||||
}) {
|
||||
@@ -122,8 +124,8 @@ export const useConnectionsStore = defineStore('connections', {
|
||||
|
||||
// 更新连接 Action
|
||||
// 更新参数类型以包含 proxy_id 和 tag_ids
|
||||
// Update parameter type to include 'type'
|
||||
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP'; password?: string; private_key?: string; passphrase?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
|
||||
// Update parameter type to include 'type' and VNC fields
|
||||
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
@@ -282,5 +284,26 @@ export const useConnectionsStore = defineStore('connections', {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// +++ 新增:获取 VNC 会话令牌 +++
|
||||
async getVncSessionToken(connectionId: number): Promise<string | null> {
|
||||
// this.isLoading = true; // 考虑是否需要独立的加载状态,或者由调用方处理
|
||||
// this.error = null;
|
||||
try {
|
||||
// 调用后端 API GET /connections/:id/vnc-session
|
||||
const response = await apiClient.get<{ token: string }>(`/connections/${connectionId}/vnc-session`);
|
||||
return response.data.token;
|
||||
} catch (err: any) {
|
||||
console.error(`获取 VNC 会话令牌失败 (连接 ID: ${connectionId}):`, err);
|
||||
// this.error = err.response?.data?.message || err.message || '获取 VNC 会话令牌时发生未知错误。';
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('未授权,需要登录才能获取 VNC 会话令牌。');
|
||||
}
|
||||
// 对于这种一次性获取数据的操作,错误通常由调用方处理并显示给用户
|
||||
throw err; // 重新抛出错误,让调用方处理
|
||||
} finally {
|
||||
// this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,7 +320,7 @@ const getTagNames = (tagIds: number[] | undefined): string[] => {
|
||||
<li v-for="conn in filteredAndSortedConnections" :key="conn.id" class="flex items-center justify-between p-3 bg-header/50 border border-border/50 rounded transition duration-150 ease-in-out">
|
||||
<div class="flex-grow mr-4 overflow-hidden">
|
||||
<span class="font-medium block truncate flex items-center" :title="conn.name || ''">
|
||||
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<i :class="['fas', conn.type === 'RDP' || conn.type === 'VNC' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<span>{{ conn.name || t('connections.unnamed') }}</span>
|
||||
</span>
|
||||
<span class="text-sm text-text-secondary block truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Use a lightweight Node.js image
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY packages/vnc/package.json packages/vnc/package-lock.json* ./
|
||||
|
||||
# Install ALL dependencies (including devDependencies like typescript)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code and tsconfig
|
||||
COPY packages/vnc/src ./src
|
||||
COPY packages/vnc/tsconfig.json ./tsconfig.json
|
||||
|
||||
# Build the TypeScript code
|
||||
RUN npm run build
|
||||
|
||||
# Remove development dependencies after build
|
||||
RUN npm prune --production
|
||||
|
||||
# --- Production Stage ---
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built code and node_modules from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY packages/vnc/package.json ./package.json
|
||||
|
||||
# --- Add patch application steps ---
|
||||
# Copy the patches directory from the build context (relative to project root)
|
||||
COPY patches ./patches
|
||||
|
||||
# Install patch-package temporarily to apply patches
|
||||
# Note: We install it here again in case prune removed it, and ensure it's available in the final stage.
|
||||
# Using --no-save as we don't need it in the final package.json dependencies.
|
||||
RUN npm install patch-package --no-save
|
||||
|
||||
# Apply patches
|
||||
RUN npx patch-package --error-on-fail
|
||||
|
||||
# Uninstall patch-package after applying to keep the image clean
|
||||
RUN npm uninstall patch-package
|
||||
# --- End patch application steps ---
|
||||
|
||||
# Expose the API and WebSocket ports
|
||||
EXPOSE 9091
|
||||
EXPOSE 8082
|
||||
|
||||
# Command to run the application
|
||||
CMD ["node", "dist/server.js"]
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module 'guacamole-lite';
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@nexus-terminal/vnc",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"dev": "nodemon --exec \"ts-node --files src/server.ts\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "VNC service for Nexus Terminal",
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^22.15.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"nodemon": "^3.1.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"guacamole-lite": "^0.7.3",
|
||||
"ws": "^8.18.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// @ts-ignore - Still need this for the import as no types exist
|
||||
import GuacamoleLite from 'guacamole-lite';
|
||||
import express, { Request, Response } from 'express';
|
||||
import http from 'http';
|
||||
import crypto from 'crypto';
|
||||
import cors from 'cors';
|
||||
|
||||
|
||||
// --- 配置 ---
|
||||
const GUAC_WS_PORT = process.env.VNC_WS_PORT || 8082;
|
||||
const API_PORT = process.env.VNC_PORT || 9091;
|
||||
const GUACD_HOST = process.env.GUACD_HOST || 'localhost';
|
||||
const GUACD_PORT = parseInt(process.env.GUACD_PORT || '4822', 10);
|
||||
|
||||
// --- 启动时生成内存加密密钥 ---
|
||||
console.log("正在为此会话生成新的内存加密密钥...");
|
||||
const ENCRYPTION_KEY_STRING = crypto.randomBytes(32).toString('hex');
|
||||
const ENCRYPTION_KEY_BUFFER = Buffer.from(ENCRYPTION_KEY_STRING, 'hex');
|
||||
console.log("内存加密密钥已生成。");
|
||||
|
||||
// --- Express 应用设置 ---
|
||||
const app = express();
|
||||
const apiServer = http.createServer(app);
|
||||
|
||||
const allowedOrigins = [
|
||||
process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
process.env.MAIN_BACKEND_URL || 'http://localhost:3000'
|
||||
];
|
||||
app.use(cors({ origin: allowedOrigins }));
|
||||
|
||||
|
||||
const guacdOptions = {
|
||||
host: GUACD_HOST,
|
||||
port: GUACD_PORT,
|
||||
};
|
||||
|
||||
const websocketOptions = {
|
||||
port: GUAC_WS_PORT,
|
||||
host: '0.0.0.0',
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
// 将实际的密钥 Buffer 传递给 guacamole-lite 用于其内部加密操作
|
||||
key: ENCRYPTION_KEY_BUFFER,
|
||||
cypher: 'aes-256-cbc' // 确保加密和解密之间的密码算法一致
|
||||
},
|
||||
// 默认连接设置
|
||||
connectionDefaultSettings: {
|
||||
// VNC 通常不需要像 RDP 那样的特定默认连接设置
|
||||
// 参数将主要通过 API 请求提供
|
||||
},
|
||||
};
|
||||
|
||||
let guacServer: any;
|
||||
|
||||
try {
|
||||
console.log(`[VNC 服务] 正在使用选项初始化 GuacamoleLite: WS 端口=${websocketOptions.port}, Guacd=${guacdOptions.host}:${guacdOptions.port}`);
|
||||
guacServer = new GuacamoleLite(websocketOptions, guacdOptions, clientOptions);
|
||||
console.log(`[VNC 服务] GuacamoleLite 初始化成功。`);
|
||||
|
||||
if (guacServer.on) {
|
||||
guacServer.on('error', (error: Error) => {
|
||||
console.error(`[VNC 服务] GuacamoleLite 服务器错误:`, error);
|
||||
});
|
||||
guacServer.on('connection', (client: any) => {
|
||||
const clientId = client.id || '未知客户端ID';
|
||||
console.log(`[VNC 服务] Guacd 连接事件触发。客户端 ID: ${clientId}`);
|
||||
|
||||
|
||||
if (client && typeof client.on === 'function') {
|
||||
client.on('disconnect', (reason: string) => {
|
||||
console.log(`[VNC 服务] Guacd 连接断开。客户端 ID: ${clientId}, 原因: ${reason || '未知'}`);
|
||||
});
|
||||
client.on('error', (err: Error) => {
|
||||
console.error(`[VNC 服务] Guacd 客户端错误。客户端 ID: ${clientId}, 错误:`, err);
|
||||
});
|
||||
|
||||
client.on('message', (message: Buffer | string) => {
|
||||
// 在回滚状态下移除了消息处理
|
||||
});
|
||||
|
||||
} else {
|
||||
// 对没有 'on' 方法的客户端进行最小化处理
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[VNC 服务] 初始化 GuacamoleLite 失败:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 更新了 encryptToken 以匹配 guacamole-lite 期望的格式 (aes-256-cbc 和特定的 JSON 结构)
|
||||
// 现在直接接受密钥 Buffer 以进行正确的加密操作
|
||||
const encryptToken = (data: string, keyBuffer: Buffer): string => {
|
||||
try {
|
||||
const iv = crypto.randomBytes(16); // AES-CBC 通常使用 16 字节的 IV
|
||||
// 使用密钥 Buffer 进行 Node.js 加密操作
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv);
|
||||
|
||||
let encrypted = cipher.update(data, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
// 构建 guacamole-lite 的解密函数期望的 JSON 对象
|
||||
const output = {
|
||||
iv: iv.toString('base64'),
|
||||
value: encrypted
|
||||
};
|
||||
|
||||
// 将 JSON 字符串化,然后对整个字符串进行 Base64 编码
|
||||
const jsonString = JSON.stringify(output);
|
||||
return Buffer.from(jsonString).toString('base64');
|
||||
|
||||
} catch (e) {
|
||||
console.error("令牌加密失败:", e);
|
||||
throw new Error("令牌加密失败。");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
app.get('/api/get-vnc-token', (req: any, res: any) => {
|
||||
const { hostname, port, password } = req.query; // VNC 主要参数
|
||||
|
||||
if (!hostname || !port || typeof password === 'undefined') {
|
||||
return res.status(400).json({ error: '缺少必需的 VNC 参数 (hostname, port, password)' });
|
||||
}
|
||||
|
||||
const connectionParams: any = { // 使用 any 类型以允许动态添加参数
|
||||
connection: {
|
||||
type: 'vnc',
|
||||
settings: {
|
||||
hostname: hostname as string,
|
||||
port: port as string,
|
||||
password: password as string,
|
||||
// 从查询中包含动态(或默认)的大小参数
|
||||
width: String(req.query.width || '1024'),
|
||||
height: String(req.query.height || '768'),
|
||||
// VNC 特有的参数可以根据需要在这里添加
|
||||
// 例如: 'username': req.query.username (如果 VNC 服务器需要)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 如果提供了 username,则添加到 settings 中
|
||||
if (req.query.username) {
|
||||
connectionParams.connection.settings.username = req.query.username as string;
|
||||
}
|
||||
|
||||
// Guacamole VNC 支持的其他参数可以类似地添加
|
||||
// 例如: 'enable-audio': req.query.enableAudio || 'false'
|
||||
|
||||
try {
|
||||
const tokenData = JSON.stringify(connectionParams);
|
||||
const encryptedToken = encryptToken(tokenData, ENCRYPTION_KEY_BUFFER);
|
||||
res.json({ token: encryptedToken });
|
||||
} catch (error) {
|
||||
console.error("/api/get-vnc-token 接口出错:", error);
|
||||
res.status(500).json({ error: '生成令牌失败' });
|
||||
}
|
||||
});
|
||||
|
||||
apiServer.listen(API_PORT, () => {
|
||||
console.log(`[VNC 服务] API 服务器正在监听端口 ${API_PORT}`);
|
||||
console.log(`[VNC 服务] Guacamole WebSocket 服务器应在端口 ${GUAC_WS_PORT} 上运行 (由 GuacamoleLite 管理)`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = (signal: string) => {
|
||||
console.log(`收到 ${signal} 信号。正在优雅地关闭...`);
|
||||
|
||||
let guacClosed = false;
|
||||
let apiClosed = false;
|
||||
|
||||
const tryExit = () => {
|
||||
if (guacClosed && apiClosed) {
|
||||
console.log("所有服务器已关闭。正在退出。");
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
apiServer.close((err) => {
|
||||
if (err) {
|
||||
console.error("关闭 API 服务器时出错:", err);
|
||||
} else {
|
||||
console.log("API 服务器已关闭。");
|
||||
}
|
||||
apiClosed = true;
|
||||
tryExit();
|
||||
});
|
||||
|
||||
// @ts-ignore - 假设基于通用模式存在 close 方法
|
||||
if (typeof guacServer !== 'undefined' && guacServer && typeof guacServer.close === 'function') {
|
||||
console.log("正在关闭 Guacamole 服务器..."); // 添加了关闭日志
|
||||
// @ts-ignore
|
||||
guacServer.close(() => {
|
||||
console.log("Guacamole 服务器已关闭。"); // 添加了关闭日志
|
||||
guacClosed = true;
|
||||
tryExit();
|
||||
});
|
||||
} else {
|
||||
console.log("Guacamole 服务器未运行或不支持 close() 方法。");
|
||||
guacClosed = true;
|
||||
tryExit();
|
||||
}
|
||||
|
||||
// 超时后强制退出
|
||||
setTimeout(() => {
|
||||
console.error("关闭超时。强制退出。");
|
||||
process.exit(1);
|
||||
}, 10000); // 10 秒超时
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
|
||||
process.on('SIGUSR2', () => {
|
||||
gracefulShutdown('SIGUSR2 (nodemon restart)');
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "libReplacement": true, /* Enable lib replacement. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"rootDir": "src", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
"resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "dist", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": [
|
||||
"src/**/*", // Include all files in the src directory
|
||||
"guacamole-lite.d.ts" // Include the specific .d.ts file
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user