This commit is contained in:
Baobhan Sith
2025-05-07 19:25:45 +08:00
parent 7510747359
commit 6ba859227d
21 changed files with 983 additions and 181 deletions
@@ -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);
+1 -1
View File
@@ -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;