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;
@@ -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>
+4
View File
@@ -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...",
+5 -1
View File
@@ -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}`">
+54
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
declare module 'guacamole-lite';
+30
View File
@@ -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"
}
}
+219
View File
@@ -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)');
});
+117
View File
@@ -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
]
}