update
This commit is contained in:
@@ -1,13 +1,8 @@
|
|||||||
# local/docker
|
# local/docker
|
||||||
DEPLOYMENT_MODE=local
|
DEPLOYMENT_MODE=local
|
||||||
|
|
||||||
RDP_SERVICE_URL_DOCKER=ws://rdp:8081
|
|
||||||
RDP_SERVICE_URL_LOCAL=ws://localhost:8081
|
|
||||||
VNC_SERVICE_URL_DOCKER=ws://vnc:8082
|
|
||||||
VNC_SERVICE_URL_LOCAL=ws://localhost:8082
|
|
||||||
|
|
||||||
# Backend API Base URLs
|
# Backend API Base URLs
|
||||||
RDP_BACKEND_API_BASE_DOCKER=http://nexus-rdp:9090
|
REMOTE_GATEWAY_API_BASE_LOCAL=http://localhost:9090
|
||||||
RDP_BACKEND_API_BASE_LOCAL=http://localhost:9090
|
REMOTE_GATEWAY_API_BASE_DOCKER=http://remote-gateway:9090
|
||||||
VNC_BACKEND_API_BASE_DOCKER=http://nexus-vnc:9091
|
REMOTE_GATEWAY_WS_URL_LOCAL=ws://localhost:8080
|
||||||
VNC_BACKEND_API_BASE_LOCAL=http://localhost:9091
|
REMOTE_GATEWAY_WS_URL_DOCKER=ws://remote-gateway:8080
|
||||||
|
|||||||
+14
-26
@@ -6,7 +6,7 @@ services:
|
|||||||
- "18111:80"
|
- "18111:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- rdp
|
- remote-gateway # 更新依赖
|
||||||
networks:
|
networks:
|
||||||
- nexus-terminal-network
|
- nexus-terminal-network
|
||||||
|
|
||||||
@@ -14,44 +14,32 @@ services:
|
|||||||
image: heavrnl/nexus-terminal-backend:latest
|
image: heavrnl/nexus-terminal-backend:latest
|
||||||
container_name: nexus-terminal-backend
|
container_name: nexus-terminal-backend
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
RDP_BACKEND_API_BASE: http://rdp:9090
|
REMOTE_GATEWAY_API_BASE: http://remote-gateway:9090 # 更新环境变量
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
networks:
|
networks:
|
||||||
- nexus-terminal-network
|
- nexus-terminal-network
|
||||||
|
|
||||||
rdp:
|
remote-gateway:
|
||||||
image: heavrnl/nexus-terminal-rdp:latest
|
build:
|
||||||
container_name: nexus-terminal-rdp
|
context: .
|
||||||
|
dockerfile: packages/remote-gateway/Dockerfile
|
||||||
|
container_name: nexus-terminal-remote-gateway
|
||||||
environment:
|
environment:
|
||||||
GUACD_HOST: guacd
|
GUACD_HOST: guacd
|
||||||
GUACD_PORT: 4822
|
GUACD_PORT: 4822
|
||||||
API_PORT: 9090
|
REMOTE_GATEWAY_API_PORT: 9090
|
||||||
GUAC_WS_PORT: 8081
|
REMOTE_GATEWAY_WS_PORT: 8080 # 与 server.ts 中的默认值一致
|
||||||
FRONTEND_URL: http://frontend
|
FRONTEND_URL: http://frontend # 或者实际的前端部署地址
|
||||||
MAIN_BACKEND_URL: http://backend:3001
|
|
||||||
NODE_ENV: production
|
|
||||||
networks:
|
|
||||||
- nexus-terminal-network
|
|
||||||
depends_on:
|
|
||||||
- guacd
|
|
||||||
- backend
|
|
||||||
|
|
||||||
vnc:
|
|
||||||
image: heavrnl/nexus-terminal-vnc:latest
|
|
||||||
container_name: nexus-terminal-vnc
|
|
||||||
environment:
|
|
||||||
GUACD_HOST: guacd
|
|
||||||
GUACD_PORT: 4822
|
|
||||||
API_PORT: 9091
|
|
||||||
GUAC_WS_PORT: 8082
|
|
||||||
FRONTEND_URL: http://frontend
|
|
||||||
MAIN_BACKEND_URL: http://backend:3001
|
MAIN_BACKEND_URL: http://backend:3001
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
ports: # 可选:如果需要从主机直接访问 API 或 WS 端口
|
||||||
|
- "9090:9090"
|
||||||
|
- "8080:8080"
|
||||||
networks:
|
networks:
|
||||||
- nexus-terminal-network
|
- nexus-terminal-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -321,7 +321,13 @@ export const getRdpSessionToken = async (req: Request, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 调用 GuacamoleService 获取 RDP 令牌
|
// 4. 调用 GuacamoleService 获取 RDP 令牌
|
||||||
const guacamoleToken = await GuacamoleService.getRdpToken(connection, decryptedPassword);
|
// 注意:从 connection.extras 或其他地方获取 RDP 特定的 width, height, dpi
|
||||||
|
const { width, height, dpi } = req.query; // 或者从 connection.extras 获取
|
||||||
|
const rdpWidth = width ? parseInt(width as string, 10) : undefined;
|
||||||
|
const rdpHeight = height ? parseInt(height as string, 10) : undefined;
|
||||||
|
const rdpDpi = dpi ? dpi as string : undefined;
|
||||||
|
|
||||||
|
const guacamoleToken = await GuacamoleService.getRemoteDesktopToken('rdp', connection, decryptedPassword, rdpWidth, rdpHeight, rdpDpi);
|
||||||
|
|
||||||
console.log(`[Controller:getRdpSessionToken] Received Guacamole token via GuacamoleService for RDP connection ${connectionId}`);
|
console.log(`[Controller:getRdpSessionToken] Received Guacamole token via GuacamoleService for RDP connection ${connectionId}`);
|
||||||
|
|
||||||
@@ -329,39 +335,35 @@ export const getRdpSessionToken = async (req: Request, res: Response): Promise<v
|
|||||||
res.status(200).json({ token: guacamoleToken });
|
res.status(200).json({ token: guacamoleToken });
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message); // Log error message
|
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message);
|
||||||
|
|
||||||
let statusCode = 500;
|
let statusCode = 500;
|
||||||
let responseMessage = '获取 RDP 会话令牌时发生内部服务器错误。';
|
let responseMessage = '获取 RDP 会话令牌时发生内部服务器错误。';
|
||||||
|
|
||||||
// 检查错误是否来自 GuacamoleService 或其内部的 axios 调用
|
if (error.message.includes('调用 RDP 后端服务失败') || error.message.includes('从 RDP 后端获取令牌失败') || error.message.includes('调用 Remote Gateway API 时出错 (RDP)')) {
|
||||||
if (error.message.includes('调用 RDP 后端服务失败') || error.message.includes('从 RDP 后端获取令牌失败')) {
|
|
||||||
responseMessage = error.message;
|
responseMessage = error.message;
|
||||||
// 尝试从错误消息中提取状态码,或者根据消息内容判断
|
if (error.message.includes('(状态: 4')) statusCode = 400;
|
||||||
if (error.message.includes('(状态: 4')) statusCode = 400; // 例如 400, 401, 404
|
else if (error.message.includes('(状态: 5')) statusCode = 502;
|
||||||
else if (error.message.includes('(状态: 5')) statusCode = 502; // 例如 500, 502, 503, 504
|
else statusCode = 503;
|
||||||
else statusCode = 503; // Service Unavailable or other specific error
|
} else if (error.message.includes('RDP 连接需要使用密码认证') || error.message.includes('密码解密失败') || error.message.includes('RDP 连接使用密码认证,但密码解密失败或未提供密码')) {
|
||||||
} else if (error.message.includes('RDP 连接需要使用密码认证') || error.message.includes('密码解密失败')) {
|
|
||||||
responseMessage = error.message;
|
responseMessage = error.message;
|
||||||
statusCode = 400;
|
statusCode = 400;
|
||||||
} else if (error.message.includes('连接类型必须是 RDP')) {
|
} else if (error.message.includes('连接类型必须是 RDP')) {
|
||||||
responseMessage = error.message;
|
responseMessage = error.message;
|
||||||
statusCode = 400;
|
statusCode = 400;
|
||||||
}
|
}
|
||||||
// 可以保留对 axios.isAxiosError 的检查,以防 GuacamoleService 抛出未包装的 axios 错误
|
|
||||||
// 但理想情况下,GuacamoleService 应该抛出更具体的错误。
|
|
||||||
else if (axios.isAxiosError(error)) {
|
else if (axios.isAxiosError(error)) {
|
||||||
responseMessage = '调用 RDP 后端服务时发生网络或请求错误。';
|
responseMessage = '调用远程桌面网关服务时发生网络或请求错误。';
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
console.error('[Controller:getRdpSessionToken] RDP backend error response (unhandled by GuacamoleService):', error.response.data);
|
console.error('[Controller:getRdpSessionToken] Remote Gateway error response:', error.response.data);
|
||||||
responseMessage += ` (状态: ${error.response.status})`;
|
responseMessage += ` (状态: ${error.response.status})`;
|
||||||
statusCode = error.response.status >= 500 ? 502 : 400;
|
statusCode = error.response.status >= 500 ? 502 : 400;
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
console.error('[Controller:getRdpSessionToken] No response from RDP backend (unhandled by GuacamoleService).');
|
console.error('[Controller:getRdpSessionToken] No response from Remote Gateway.');
|
||||||
responseMessage += ' (无法连接或超时)';
|
responseMessage += ' (无法连接或超时)';
|
||||||
statusCode = 504;
|
statusCode = 504;
|
||||||
}
|
}
|
||||||
} else if (error.message.includes('解密失败')) { // General decryption error from ConnectionService
|
} else if (error.message.includes('解密失败')) {
|
||||||
responseMessage = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
|
responseMessage = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
|
||||||
}
|
}
|
||||||
res.status(statusCode).json({ message: responseMessage });
|
res.status(statusCode).json({ message: responseMessage });
|
||||||
@@ -380,7 +382,6 @@ export const getVncSessionToken = async (req: Request, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 获取连接信息和解密后的凭证
|
|
||||||
const connectionData = await ConnectionService.getConnectionWithDecryptedCredentials(connectionId);
|
const connectionData = await ConnectionService.getConnectionWithDecryptedCredentials(connectionId);
|
||||||
|
|
||||||
if (!connectionData) {
|
if (!connectionData) {
|
||||||
@@ -390,13 +391,11 @@ export const getVncSessionToken = async (req: Request, res: Response): Promise<v
|
|||||||
|
|
||||||
const { connection, decryptedPassword } = connectionData;
|
const { connection, decryptedPassword } = connectionData;
|
||||||
|
|
||||||
// 2. 验证连接类型是否为 VNC
|
|
||||||
if (connection.type !== 'VNC') {
|
if (connection.type !== 'VNC') {
|
||||||
res.status(400).json({ message: '此连接类型不是 VNC。' });
|
res.status(400).json({ message: '此连接类型不是 VNC。' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 更新 last_connected_at
|
|
||||||
try {
|
try {
|
||||||
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
const currentTimeSeconds = Math.floor(Date.now() / 1000);
|
||||||
await ConnectionRepository.updateLastConnected(connectionId, currentTimeSeconds);
|
await ConnectionRepository.updateLastConnected(connectionId, currentTimeSeconds);
|
||||||
@@ -405,58 +404,52 @@ export const getVncSessionToken = async (req: Request, res: Response): Promise<v
|
|||||||
console.error(`[Controller:getVncSessionToken] 更新 VNC 连接 ${connectionId} 的 last_connected_at 时出错:`, updateError);
|
console.error(`[Controller:getVncSessionToken] 更新 VNC 连接 ${connectionId} 的 last_connected_at 时出错:`, updateError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 验证 VNC 连接是否使用密码认证 (VNC 通常总是需要密码)
|
|
||||||
if (connection.auth_method !== 'password' || !decryptedPassword) {
|
if (connection.auth_method !== 'password' || !decryptedPassword) {
|
||||||
console.warn(`[Controller:getVncSessionToken] VNC connection ${connectionId} does not use password auth or password decryption failed.`);
|
console.warn(`[Controller:getVncSessionToken] VNC connection ${connectionId} does not use password auth or password decryption failed.`);
|
||||||
res.status(400).json({ message: 'VNC 连接需要使用密码认证,或密码解密失败。' });
|
res.status(400).json({ message: 'VNC 连接需要使用密码认证,或密码解密失败。' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 从查询参数中获取可选的 width 和 height
|
|
||||||
const { width, height } = req.query;
|
const { width, height } = req.query;
|
||||||
const initialWidth = width ? parseInt(width as string, 10) : undefined;
|
const initialWidth = width ? parseInt(width as string, 10) : undefined;
|
||||||
const initialHeight = height ? parseInt(height as string, 10) : undefined;
|
const initialHeight = height ? parseInt(height as string, 10) : undefined;
|
||||||
|
|
||||||
// 6. 调用 GuacamoleService 获取 VNC 令牌,传递尺寸信息
|
const guacamoleToken = await GuacamoleService.getRemoteDesktopToken('vnc', connection, decryptedPassword, initialWidth, initialHeight);
|
||||||
const guacamoleToken = await GuacamoleService.getVncToken(connection, decryptedPassword, initialWidth, initialHeight);
|
|
||||||
|
|
||||||
console.log(`[Controller:getVncSessionToken] Received Guacamole token via GuacamoleService for VNC connection ${connectionId} with size ${initialWidth}x${initialHeight}`);
|
console.log(`[Controller:getVncSessionToken] Received Guacamole token via GuacamoleService for VNC connection ${connectionId} with size ${initialWidth}x${initialHeight}`);
|
||||||
|
|
||||||
// 6. 将 Guacamole 令牌返回给前端
|
|
||||||
res.status(200).json({ token: guacamoleToken });
|
res.status(200).json({ token: guacamoleToken });
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Controller: 获取 VNC 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message); // Log error message
|
console.error(`Controller: 获取 VNC 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message);
|
||||||
|
|
||||||
let statusCode = 500;
|
let statusCode = 500;
|
||||||
let responseMessage = '获取 VNC 会话令牌时发生内部服务器错误。';
|
let responseMessage = '获取 VNC 会话令牌时发生内部服务器错误。';
|
||||||
|
|
||||||
// 检查错误是否来自 GuacamoleService 或其内部的 axios 调用
|
if (error.message.includes('调用 VNC 后端服务失败') || error.message.includes('从 VNC 后端获取令牌失败') || error.message.includes('调用 Remote Gateway API 时出错 (VNC)')) {
|
||||||
if (error.message.includes('调用 VNC 后端服务失败') || error.message.includes('从 VNC 后端获取令牌失败')) {
|
|
||||||
responseMessage = error.message;
|
responseMessage = error.message;
|
||||||
if (error.message.includes('(状态: 4')) statusCode = 400;
|
if (error.message.includes('(状态: 4')) statusCode = 400;
|
||||||
else if (error.message.includes('(状态: 5')) statusCode = 502;
|
else if (error.message.includes('(状态: 5')) statusCode = 502;
|
||||||
else statusCode = 503;
|
else statusCode = 503;
|
||||||
} else if (error.message.includes('VNC 连接需要使用密码认证') || error.message.includes('密码解密失败')) {
|
} else if (error.message.includes('VNC 连接需要使用密码认证') || error.message.includes('密码解密失败') || error.message.includes('VNC 连接使用密码认证,但密码解密失败或未提供密码')) {
|
||||||
responseMessage = error.message;
|
responseMessage = error.message;
|
||||||
statusCode = 400;
|
statusCode = 400;
|
||||||
} else if (error.message.includes('连接类型必须是 VNC')) {
|
} else if (error.message.includes('连接类型必须是 VNC')) {
|
||||||
responseMessage = error.message;
|
responseMessage = error.message;
|
||||||
statusCode = 400;
|
statusCode = 400;
|
||||||
}
|
}
|
||||||
// 可以保留对 axios.isAxiosError 的检查
|
|
||||||
else if (axios.isAxiosError(error)) {
|
else if (axios.isAxiosError(error)) {
|
||||||
responseMessage = '调用 VNC 后端服务时发生网络或请求错误。';
|
responseMessage = '调用远程桌面网关服务时发生网络或请求错误。';
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
console.error('[Controller:getVncSessionToken] VNC backend error response (unhandled by GuacamoleService):', error.response.data);
|
console.error('[Controller:getVncSessionToken] Remote Gateway error response:', error.response.data);
|
||||||
responseMessage += ` (状态: ${error.response.status})`;
|
responseMessage += ` (状态: ${error.response.status})`;
|
||||||
statusCode = error.response.status >= 500 ? 502 : 400;
|
statusCode = error.response.status >= 500 ? 502 : 400;
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
console.error('[Controller:getVncSessionToken] No response from VNC backend (unhandled by GuacamoleService).');
|
console.error('[Controller:getVncSessionToken] No response from Remote Gateway.');
|
||||||
responseMessage += ' (无法连接或超时)';
|
responseMessage += ' (无法连接或超时)';
|
||||||
statusCode = 504;
|
statusCode = 504;
|
||||||
}
|
}
|
||||||
} else if (error.message.includes('解密失败')) { // General decryption error from ConnectionService
|
} else if (error.message.includes('解密失败')) {
|
||||||
responseMessage = '获取 VNC 会话令牌时发生内部错误(凭证处理失败)。';
|
responseMessage = '获取 VNC 会话令牌时发生内部错误(凭证处理失败)。';
|
||||||
}
|
}
|
||||||
res.status(statusCode).json({ message: responseMessage });
|
res.status(statusCode).json({ message: responseMessage });
|
||||||
|
|||||||
@@ -1,131 +1,93 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { ConnectionWithTags } from '../types/connection.types';
|
import { ConnectionWithTags } from '../types/connection.types';
|
||||||
|
|
||||||
// RDP 后端服务的 Base URL
|
// 统一远程桌面网关服务的 Base URL
|
||||||
const RDP_BACKEND_API_BASE = process.env.DEPLOYMENT_MODE === 'local'
|
const REMOTE_GATEWAY_API_BASE = process.env.DEPLOYMENT_MODE === 'local'
|
||||||
? (process.env.RDP_BACKEND_API_BASE_LOCAL || 'http://localhost:9090')
|
? process.env.REMOTE_GATEWAY_API_BASE_LOCAL || 'http://localhost:9090'
|
||||||
: (process.env.RDP_BACKEND_API_BASE_DOCKER || 'http://nexus-rdp:9090');
|
: process.env.REMOTE_GATEWAY_API_BASE_DOCKER || 'http://remote-gateway:9090';
|
||||||
|
|
||||||
console.log(`[GuacamoleService] DEPLOYMENT_MODE: ${process.env.DEPLOYMENT_MODE}`);
|
console.log(`[GuacamoleService] DEPLOYMENT_MODE: ${process.env.DEPLOYMENT_MODE}`);
|
||||||
console.log(`[GuacamoleService] RDP_BACKEND_API_BASE_LOCAL: ${process.env.RDP_BACKEND_API_BASE_LOCAL}`);
|
console.log(`[GuacamoleService] Using Remote Gateway API Base (Local): ${process.env.REMOTE_GATEWAY_API_BASE_LOCAL}`);
|
||||||
console.log(`[GuacamoleService] RDP_BACKEND_API_BASE_DOCKER: ${process.env.RDP_BACKEND_API_BASE_DOCKER}`);
|
console.log(`[GuacamoleService] Using Remote Gateway API Base (Docker): ${process.env.REMOTE_GATEWAY_API_BASE_DOCKER}`);
|
||||||
console.log(`[GuacamoleService] Using RDP Backend API Base: ${RDP_BACKEND_API_BASE}`);
|
console.log(`[GuacamoleService] Effective Remote Gateway API Base: ${REMOTE_GATEWAY_API_BASE}`);
|
||||||
|
|
||||||
|
|
||||||
// VNC 后端服务的 Base URL
|
interface TokenResponse {
|
||||||
const VNC_BACKEND_API_BASE = process.env.DEPLOYMENT_MODE === 'local'
|
token: string;
|
||||||
? (process.env.VNC_BACKEND_API_BASE_LOCAL || 'http://localhost:9091')
|
}
|
||||||
: (process.env.VNC_BACKEND_API_BASE_DOCKER || 'http://nexus-vnc:9091');
|
|
||||||
|
|
||||||
console.log(`[GuacamoleService] VNC_BACKEND_API_BASE_LOCAL: ${process.env.VNC_BACKEND_API_BASE_LOCAL}`);
|
|
||||||
console.log(`[GuacamoleService] VNC_BACKEND_API_BASE_DOCKER: ${process.env.VNC_BACKEND_API_BASE_DOCKER}`);
|
|
||||||
console.log(`[GuacamoleService] Using VNC Backend API Base: ${VNC_BACKEND_API_BASE}`);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 RDP 后端服务获取 Guacamole 令牌
|
* 从统一远程桌面网关服务获取 Guacamole 令牌
|
||||||
|
* @param protocol 'rdp' 或 'vnc'
|
||||||
* @param connection 连接对象
|
* @param connection 连接对象
|
||||||
* @param decryptedPassword 解密后的密码
|
* @param decryptedPassword 解密后的密码
|
||||||
|
* @param width 宽度
|
||||||
|
* @param height 高度
|
||||||
|
* @param dpi DPI (主要用于 RDP)
|
||||||
* @returns Guacamole 令牌
|
* @returns Guacamole 令牌
|
||||||
*/
|
*/
|
||||||
export const getRdpToken = async (connection: ConnectionWithTags, decryptedPassword?: string): Promise<string> => {
|
export const getRemoteDesktopToken = async (
|
||||||
if (connection.type !== 'RDP') {
|
protocol: 'rdp' | 'vnc',
|
||||||
throw new Error('连接类型必须是 RDP。');
|
connection: ConnectionWithTags,
|
||||||
|
decryptedPassword?: string,
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
dpi?: string // DPI 主要用于 RDP
|
||||||
|
): Promise<string> => {
|
||||||
|
if ((protocol === 'rdp' || protocol === 'vnc') && connection.auth_method === 'password' && !decryptedPassword) {
|
||||||
|
console.warn(`[GuacamoleService:getRemoteDesktopToken] ${protocol.toUpperCase()} connection ${connection.id} uses password auth but password decryption failed or password not provided.`);
|
||||||
|
throw new Error(`${protocol.toUpperCase()} 连接使用密码认证,但密码解密失败或未提供密码。`);
|
||||||
}
|
}
|
||||||
if (connection.auth_method !== 'password' || !decryptedPassword) {
|
|
||||||
console.warn(`[GuacamoleService:getRdpToken] RDP connection ${connection.id} does not use password auth or password decryption failed.`);
|
const connectionConfig: any = {
|
||||||
throw new Error('RDP 连接需要使用密码认证,或密码解密失败。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rdpApiParams = new URLSearchParams({
|
|
||||||
hostname: connection.host,
|
hostname: connection.host,
|
||||||
port: connection.port.toString(),
|
port: connection.port.toString(),
|
||||||
username: connection.username,
|
width: String(width || 1024), // 提供默认值
|
||||||
password: decryptedPassword,
|
height: String(height || 768), // 提供默认值
|
||||||
// 确保传递 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}`);
|
if (protocol === 'rdp') {
|
||||||
|
if (!connection.username) {
|
||||||
|
console.warn(`[GuacamoleService:getRemoteDesktopToken] RDP connection ${connection.id} is missing username.`);
|
||||||
|
// 对于RDP,用户名通常是必需的,但让网关决定是否可以为空
|
||||||
|
}
|
||||||
|
connectionConfig.username = connection.username || ''; // RDP 通常需要用户名
|
||||||
|
connectionConfig.password = decryptedPassword || ''; // RDP 通常需要密码
|
||||||
|
connectionConfig.dpi = dpi || '96';
|
||||||
|
connectionConfig.security = (connection as any).rdp_security || 'any';
|
||||||
|
connectionConfig.ignoreCert = String((connection as any).rdp_ignore_cert ?? true);
|
||||||
|
} else if (protocol === 'vnc') {
|
||||||
|
connectionConfig.password = decryptedPassword || ''; // VNC 通常需要密码
|
||||||
|
if (connection.username) { // VNC 用户名是可选的
|
||||||
|
connectionConfig.username = connection.username;
|
||||||
|
}
|
||||||
|
// 其他 VNC 特定参数可以从 connection.extras 获取
|
||||||
|
// 例如: if (connection.extras?.enableAudio) connectionConfig.enableAudio = connection.extras.enableAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
protocol,
|
||||||
|
connectionConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenUrl = `${REMOTE_GATEWAY_API_BASE}/api/remote-desktop/token`;
|
||||||
|
console.log(`[GuacamoleService:getRemoteDesktopToken] Calling Remote Gateway API: ${tokenUrl} for protocol ${protocol}, connection ${connection.id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rdpResponse = await axios.get<{ token: string }>(rdpTokenUrl, {
|
const response = await axios.post<TokenResponse>(tokenUrl, requestBody, {
|
||||||
timeout: 10000 // 10 秒超时
|
timeout: 10000 // 10 秒超时
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rdpResponse.status !== 200 || !rdpResponse.data?.token) {
|
if (response.status !== 200 || !response.data?.token) {
|
||||||
console.error(`[GuacamoleService:getRdpToken] RDP backend API call failed or returned invalid data. Status: ${rdpResponse.status}`, rdpResponse.data);
|
console.error(`[GuacamoleService:getRemoteDesktopToken] ${protocol.toUpperCase()} backend API call failed or returned invalid data. Status: ${response.status}`, response.data);
|
||||||
throw new Error('从 RDP 后端获取令牌失败。');
|
throw new Error(`从 ${protocol.toUpperCase()} 后端获取令牌失败。`);
|
||||||
}
|
}
|
||||||
console.log(`[GuacamoleService:getRdpToken] Received Guacamole token from RDP backend for connection ${connection.id}`);
|
console.log(`[GuacamoleService:getRemoteDesktopToken] Received Guacamole token from ${protocol.toUpperCase()} backend for connection ${connection.id}`);
|
||||||
return rdpResponse.data.token;
|
return response.data.token;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[GuacamoleService:getRdpToken] Error calling RDP backend for connection ${connection.id}:`, error.message);
|
console.error(`[GuacamoleService:getRemoteDesktopToken] Error calling ${protocol.toUpperCase()} backend for connection ${connection.id}:`, error.message);
|
||||||
if (axios.isAxiosError(error) && error.response) {
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
throw new Error(`调用 RDP 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`);
|
throw new Error(`调用 ${protocol.toUpperCase()} 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`);
|
||||||
}
|
}
|
||||||
throw new Error(`调用 RDP 后端服务时发生错误: ${error.message}`);
|
throw new Error(`调用 ${protocol.toUpperCase()} 后端服务时发生错误: ${error.message}`);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 VNC 后端服务获取 Guacamole 令牌
|
|
||||||
* @param connection 连接对象
|
|
||||||
* @param decryptedPassword 解密后的密码 (VNC 通常需要密码)
|
|
||||||
* @returns Guacamole 令牌
|
|
||||||
*/
|
|
||||||
export const getVncToken = async (connection: ConnectionWithTags, decryptedPassword?: string, width?: number, height?: number): 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 服务支持用户名
|
|
||||||
});
|
|
||||||
|
|
||||||
if (width !== undefined) {
|
|
||||||
vncApiParams.append('width', String(width));
|
|
||||||
}
|
|
||||||
if (height !== undefined) {
|
|
||||||
vncApiParams.append('height', String(height));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果 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}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -530,26 +530,27 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
|||||||
|
|
||||||
|
|
||||||
// Determine RDP target URL based on deployment mode
|
// Determine RDP target URL based on deployment mode
|
||||||
const deploymentMode = process.env.DEPLOYMENT_MODE; // Default to docker mode
|
const deploymentMode = process.env.DEPLOYMENT_MODE;
|
||||||
let rdpBaseUrl: string;
|
let remoteGatewayWsBaseUrl: string;
|
||||||
if (deploymentMode === 'local') {
|
if (deploymentMode === 'local') {
|
||||||
rdpBaseUrl = process.env.RDP_SERVICE_URL_LOCAL || 'ws://localhost:8081'; // Default for local, fallback to localhost:3001
|
remoteGatewayWsBaseUrl = process.env.REMOTE_GATEWAY_WS_URL_LOCAL || 'ws://localhost:8080'; // 更新端口和环境变量名
|
||||||
console.log(`[WebSocket RDP Proxy] Using LOCAL deployment mode. RDP Target Base: ${rdpBaseUrl}`);
|
console.log(`[WebSocket Remote Desktop Proxy] Using LOCAL deployment mode. Target Base: ${remoteGatewayWsBaseUrl}`);
|
||||||
} else if (deploymentMode === 'docker') { // Explicitly check for docker mode
|
} else if (deploymentMode === 'docker') {
|
||||||
rdpBaseUrl = process.env.RDP_SERVICE_URL_DOCKER || 'ws://rdp:8081'; // Default for docker, fallback to localhost:3001
|
remoteGatewayWsBaseUrl = process.env.REMOTE_GATEWAY_WS_URL_DOCKER || 'ws://remote-gateway:8080'; // 更新服务名、端口和环境变量名
|
||||||
console.log(`[WebSocket RDP Proxy] Using DOCKER deployment mode. RDP Target Base: ${rdpBaseUrl}`);
|
console.log(`[WebSocket Remote Desktop Proxy] Using DOCKER deployment mode. Target Base: ${remoteGatewayWsBaseUrl}`);
|
||||||
} else { // Handle unknown modes
|
} else {
|
||||||
rdpBaseUrl = 'ws://localhost:8081'; // Fallback to a safe default for unknown modes
|
remoteGatewayWsBaseUrl = 'ws://localhost:8080'; // 更新默认端口
|
||||||
console.warn(`[WebSocket RDP Proxy] Unknown deployment mode '${deploymentMode}'. Defaulting to safe fallback RDP Target Base: ${rdpBaseUrl}`);
|
console.warn(`[WebSocket Remote Desktop Proxy] Unknown deployment mode '${deploymentMode}'. Defaulting to safe fallback Target Base: ${remoteGatewayWsBaseUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanRdpBaseUrl = rdpBaseUrl.endsWith('/') ? rdpBaseUrl.slice(0, -1) : rdpBaseUrl;
|
const cleanRemoteGatewayWsBaseUrl = remoteGatewayWsBaseUrl.endsWith('/') ? remoteGatewayWsBaseUrl.slice(0, -1) : remoteGatewayWsBaseUrl;
|
||||||
|
|
||||||
const rdpTargetUrl = `${cleanRdpBaseUrl}/?token=${encodeURIComponent(rdpToken)}&width=${encodeURIComponent(rdpWidth)}&height=${encodeURIComponent(rdpHeight)}&dpi=${encodeURIComponent(calculatedDpi)}`; // 使用 calculatedDpi
|
// 构建目标 URL 时,协议 (RDP/VNC) 信息现在由 remote-gateway 处理,我们只需要传递令牌和尺寸
|
||||||
|
const remoteDesktopTargetUrl = `${cleanRemoteGatewayWsBaseUrl}/?token=${encodeURIComponent(rdpToken)}&width=${encodeURIComponent(rdpWidth)}&height=${encodeURIComponent(rdpHeight)}&dpi=${encodeURIComponent(calculatedDpi)}`;
|
||||||
|
|
||||||
console.log(`WebSocket: RDP Proxy for ${ws.username} attempting to connect to ${rdpTargetUrl}`);
|
console.log(`WebSocket: Remote Desktop Proxy for ${ws.username} attempting to connect to ${remoteDesktopTargetUrl}`);
|
||||||
|
|
||||||
const rdpWs = new WebSocket(rdpTargetUrl);
|
const rdpWs = new WebSocket(remoteDesktopTargetUrl);
|
||||||
let clientWsClosed = false;
|
let clientWsClosed = false;
|
||||||
let rdpWsClosed = false;
|
let rdpWsClosed = false;
|
||||||
|
|
||||||
@@ -586,7 +587,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
|||||||
});
|
});
|
||||||
rdpWs.on('error', (error) => {
|
rdpWs.on('error', (error) => {
|
||||||
|
|
||||||
console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${rdpTargetUrl} 时出错:`, error);
|
console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${remoteDesktopTargetUrl} 时出错:`, error);
|
||||||
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
||||||
console.log(`[RDP 代理] 因 RDP WS 错误关闭客户端 WS。会话: ${ws.sessionId}`);
|
console.log(`[RDP 代理] 因 RDP WS 错误关闭客户端 WS。会话: ${ws.sessionId}`);
|
||||||
ws.close(1011, `RDP WS Error: ${error.message}`);
|
ws.close(1011, `RDP WS Error: ${error.message}`);
|
||||||
@@ -610,7 +611,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
|||||||
rdpWs.on('close', (code, reason) => {
|
rdpWs.on('close', (code, reason) => {
|
||||||
rdpWsClosed = true;
|
rdpWsClosed = true;
|
||||||
// --- 添加中文日志 ---
|
// --- 添加中文日志 ---
|
||||||
console.log(`[RDP 代理 RDP WS 关闭] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已关闭。代码: ${code}, 原因: ${reason.toString()}`);
|
console.log(`[RDP 代理 RDP WS 关闭] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${remoteDesktopTargetUrl} 的连接已关闭。代码: ${code}, 原因: ${reason.toString()}`);
|
||||||
// --- 结束日志 ---
|
// --- 结束日志 ---
|
||||||
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
||||||
console.log(`[RDP 代理] 因 RDP WS 关闭而关闭客户端 WS。会话: ${ws.sessionId}`);
|
console.log(`[RDP 代理] 因 RDP WS 关闭而关闭客户端 WS。会话: ${ws.sessionId}`);
|
||||||
@@ -620,7 +621,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
|||||||
});
|
});
|
||||||
|
|
||||||
rdpWs.on('open', () => {
|
rdpWs.on('open', () => {
|
||||||
console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已建立。开始转发消息。`);
|
console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${remoteDesktopTargetUrl} 的连接已建立。开始转发消息。`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 标准 (SSH/SFTP/Docker) 连接处理 ---
|
// --- 标准 (SSH/SFTP/Docker) 连接处理 ---
|
||||||
|
|||||||
@@ -49,14 +49,17 @@ let dragOffsetX = 0;
|
|||||||
let dragOffsetY = 0;
|
let dragOffsetY = 0;
|
||||||
let hasDragged = false;
|
let hasDragged = false;
|
||||||
|
|
||||||
let vncWsBaseUrl: string;
|
let remoteDesktopWsBaseUrl: string; // Renamed for clarity
|
||||||
const VNC_WS_PORT_FROM_ENV = import.meta.env.VITE_VNC_WS_PORT || '8082';
|
const LOCAL_BACKEND_URL_FOR_PROXY = 'ws://localhost:3001'; // Main backend's WebSocket for proxying
|
||||||
|
|
||||||
if (window.location.hostname === 'localhost') {
|
if (window.location.hostname === 'localhost') {
|
||||||
vncWsBaseUrl = `ws://localhost:${VNC_WS_PORT_FROM_ENV}`;
|
// For local development, VNC will also go through the main backend's proxy
|
||||||
|
remoteDesktopWsBaseUrl = `${LOCAL_BACKEND_URL_FOR_PROXY}/ws/rdp-proxy`; // Use the same RDP proxy path
|
||||||
} else {
|
} else {
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
vncWsBaseUrl = `${wsProtocol}//${window.location.hostname}:${VNC_WS_PORT_FROM_ENV}`;
|
const wsHostAndPort = window.location.host;
|
||||||
|
// For deployed environments, assume the proxy is at /ws/rdp-proxy relative to the main backend
|
||||||
|
remoteDesktopWsBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws/rdp-proxy`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConnection = async () => {
|
const handleConnection = async () => {
|
||||||
@@ -76,12 +79,17 @@ const handleConnection = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const connectionsStore = useConnectionsStore();
|
const connectionsStore = useConnectionsStore();
|
||||||
|
// Pass width and height to the token generation, backend will forward to gateway
|
||||||
const token = await connectionsStore.getVncSessionToken(props.connection.id, desiredModalWidth.value, desiredModalHeight.value);
|
const token = await connectionsStore.getVncSessionToken(props.connection.id, desiredModalWidth.value, desiredModalHeight.value);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('VNC Token not found from store action');
|
throw new Error('VNC Token not found from store action');
|
||||||
}
|
}
|
||||||
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
|
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
|
||||||
const tunnelUrl = `${vncWsBaseUrl}?token=${encodeURIComponent(token)}`;
|
// The backend proxy (/ws/rdp-proxy) expects token, width, height, dpi.
|
||||||
|
// For VNC, DPI is less critical but the proxy might expect it. Send a default or let backend handle.
|
||||||
|
// The backend's websocket.ts rdp-proxy handler now calculates DPI if not provided or uses a default.
|
||||||
|
// We need to ensure width and height are passed for the proxy to correctly forward.
|
||||||
|
const tunnelUrl = `${remoteDesktopWsBaseUrl}?token=${encodeURIComponent(token)}&width=${desiredModalWidth.value}&height=${desiredModalHeight.value}`;
|
||||||
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
Generated
-1610
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
|||||||
// @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.GUAC_WS_PORT || 8081;
|
|
||||||
const API_PORT = process.env.API_PORT || 9090;
|
|
||||||
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: {
|
|
||||||
rdp: {
|
|
||||||
'security': 'nla',
|
|
||||||
'ignore-cert': 'true',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let guacServer: any;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`[RDP 服务] 正在使用选项初始化 GuacamoleLite: WS 端口=${websocketOptions.port}, Guacd=${guacdOptions.host}:${guacdOptions.port}`);
|
|
||||||
guacServer = new GuacamoleLite(websocketOptions, guacdOptions, clientOptions);
|
|
||||||
console.log(`[RDP 服务] GuacamoleLite 初始化成功。`);
|
|
||||||
|
|
||||||
if (guacServer.on) {
|
|
||||||
guacServer.on('error', (error: Error) => {
|
|
||||||
console.error(`[RDP 服务] GuacamoleLite 服务器错误:`, error);
|
|
||||||
});
|
|
||||||
guacServer.on('connection', (client: any) => {
|
|
||||||
const clientId = client.id || '未知客户端ID';
|
|
||||||
console.log(`[RDP 服务] Guacd 连接事件触发。客户端 ID: ${clientId}`);
|
|
||||||
|
|
||||||
|
|
||||||
if (client && typeof client.on === 'function') {
|
|
||||||
client.on('disconnect', (reason: string) => {
|
|
||||||
console.log(`[RDP 服务] Guacd 连接断开。客户端 ID: ${clientId}, 原因: ${reason || '未知'}`);
|
|
||||||
});
|
|
||||||
client.on('error', (err: Error) => {
|
|
||||||
console.error(`[RDP 服务] Guacd 客户端错误。客户端 ID: ${clientId}, 错误:`, err);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('message', (message: Buffer | string) => {
|
|
||||||
// 在回滚状态下移除了消息处理
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// 对没有 'on' 方法的客户端进行最小化处理
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[RDP 服务] 初始化 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-token', (req: any, res: any) => {
|
|
||||||
const { hostname, port, username, password, security = 'any', ignoreCert = 'true' } = req.query;
|
|
||||||
|
|
||||||
if (!hostname || !port || !username || typeof password === 'undefined') {
|
|
||||||
return res.status(400).json({ error: '缺少必需的 RDP 参数 (hostname, port, username, password)' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionParams = {
|
|
||||||
connection: {
|
|
||||||
type: 'rdp',
|
|
||||||
settings: {
|
|
||||||
hostname: hostname as string,
|
|
||||||
port: port as string,
|
|
||||||
username: username as string,
|
|
||||||
password: password as string,
|
|
||||||
// 从查询中包含动态(或默认)的大小参数
|
|
||||||
width: String(req.query.width || '1024'),
|
|
||||||
height: String(req.query.height || '768'),
|
|
||||||
dpi: String(req.query.dpi || '96'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tokenData = JSON.stringify(connectionParams);
|
|
||||||
const encryptedToken = encryptToken(tokenData, ENCRYPTION_KEY_BUFFER);
|
|
||||||
res.json({ token: encryptedToken });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("/api/get-token 接口出错:", error);
|
|
||||||
res.status(500).json({ error: '生成令牌失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
apiServer.listen(API_PORT, () => {
|
|
||||||
console.log(`[RDP 服务] API 服务器正在监听端口 ${API_PORT}`);
|
|
||||||
console.log(`[RDP 服务] 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)');
|
|
||||||
});
|
|
||||||
@@ -5,14 +5,15 @@ FROM node:20-alpine AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json
|
# Copy package.json and package-lock.json
|
||||||
COPY packages/rdp/package.json packages/rdp/package-lock.json* ./
|
COPY packages/remote-gateway/package.json packages/remote-gateway/package-lock.json* ./
|
||||||
|
|
||||||
# Install ALL dependencies (including devDependencies like typescript)
|
# Install ALL dependencies (including devDependencies like typescript)
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
# Copy source code and tsconfig
|
# Copy source code and tsconfig
|
||||||
COPY packages/rdp/src ./src
|
COPY packages/remote-gateway/src ./src
|
||||||
COPY packages/rdp/tsconfig.json ./tsconfig.json
|
COPY packages/remote-gateway/tsconfig.json ./tsconfig.json
|
||||||
|
COPY packages/remote-gateway/guacamole-lite.d.ts ./guacamole-lite.d.ts
|
||||||
|
|
||||||
# Build the TypeScript code
|
# Build the TypeScript code
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -28,7 +29,7 @@ WORKDIR /app
|
|||||||
# Copy built code and node_modules from builder stage
|
# Copy built code and node_modules from builder stage
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY packages/rdp/package.json ./package.json
|
COPY packages/remote-gateway/package.json ./package.json
|
||||||
|
|
||||||
# --- Add patch application steps ---
|
# --- Add patch application steps ---
|
||||||
# Copy the patches directory from the build context (relative to project root)
|
# Copy the patches directory from the build context (relative to project root)
|
||||||
@@ -47,6 +48,7 @@ RUN npm uninstall patch-package
|
|||||||
# --- End patch application steps ---
|
# --- End patch application steps ---
|
||||||
|
|
||||||
# Expose the API and WebSocket ports
|
# Expose the API and WebSocket ports
|
||||||
|
# These will be configurable via environment variables, but good to have defaults
|
||||||
EXPOSE 9090
|
EXPOSE 9090
|
||||||
EXPOSE 8081
|
EXPOSE 8081
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@nexus-terminal/rdp",
|
"name": "@nexus-terminal/remote-gateway",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "Unified Remote Desktop Gateway for Nexus Terminal",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
@@ -27,4 +27,4 @@
|
|||||||
"guacamole-lite": "^0.7.3",
|
"guacamole-lite": "^0.7.3",
|
||||||
"ws": "^8.18.1"
|
"ws": "^8.18.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// @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 REMOTE_GATEWAY_WS_PORT = process.env.REMOTE_GATEWAY_WS_PORT || 8080; // 统一端口,或按需分开
|
||||||
|
const REMOTE_GATEWAY_API_PORT = process.env.REMOTE_GATEWAY_API_PORT || 9090;
|
||||||
|
const GUACD_HOST = process.env.GUACD_HOST || 'localhost';
|
||||||
|
const GUACD_PORT = parseInt(process.env.GUACD_PORT || '4822', 10);
|
||||||
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
const MAIN_BACKEND_URL = process.env.MAIN_BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
// --- 启动时生成内存加密密钥 ---
|
||||||
|
console.log("[Remote Gateway] 正在为此会话生成新的内存加密密钥...");
|
||||||
|
const ENCRYPTION_KEY_STRING = crypto.randomBytes(32).toString('hex');
|
||||||
|
const ENCRYPTION_KEY_BUFFER = Buffer.from(ENCRYPTION_KEY_STRING, 'hex');
|
||||||
|
console.log("[Remote Gateway] 内存加密密钥已生成。");
|
||||||
|
|
||||||
|
// --- Express 应用设置 ---
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json()); // 用于解析请求体中的 JSON
|
||||||
|
const apiServer = http.createServer(app);
|
||||||
|
|
||||||
|
const allowedOrigins = [
|
||||||
|
FRONTEND_URL,
|
||||||
|
MAIN_BACKEND_URL
|
||||||
|
];
|
||||||
|
console.log(`[Remote Gateway] CORS 允许的来源: ${allowedOrigins.join(', ')}`);
|
||||||
|
app.use(cors({ origin: allowedOrigins }));
|
||||||
|
|
||||||
|
|
||||||
|
const guacdOptions = {
|
||||||
|
host: GUACD_HOST,
|
||||||
|
port: GUACD_PORT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const websocketOptions = {
|
||||||
|
port: REMOTE_GATEWAY_WS_PORT,
|
||||||
|
host: '0.0.0.0', // 监听所有接口
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientOptions = {
|
||||||
|
crypt: {
|
||||||
|
key: ENCRYPTION_KEY_BUFFER,
|
||||||
|
cypher: 'aes-256-cbc'
|
||||||
|
},
|
||||||
|
// 默认连接设置将根据协议动态调整
|
||||||
|
connectionDefaultSettings: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let guacServer: any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[Remote Gateway] 正在使用选项初始化 GuacamoleLite: WS 端口=${websocketOptions.port}, Guacd=${guacdOptions.host}:${guacdOptions.port}`);
|
||||||
|
guacServer = new GuacamoleLite(websocketOptions, guacdOptions, clientOptions);
|
||||||
|
console.log(`[Remote Gateway] GuacamoleLite 初始化成功。`);
|
||||||
|
|
||||||
|
if (guacServer.on) {
|
||||||
|
guacServer.on('error', (error: Error) => {
|
||||||
|
console.error(`[Remote Gateway] GuacamoleLite 服务器错误:`, error);
|
||||||
|
});
|
||||||
|
guacServer.on('connection', (client: any) => {
|
||||||
|
const clientId = client.id || '未知客户端ID';
|
||||||
|
console.log(`[Remote Gateway] Guacd 连接事件触发。客户端 ID: ${clientId}`);
|
||||||
|
|
||||||
|
if (client && typeof client.on === 'function') {
|
||||||
|
client.on('disconnect', (reason: string) => {
|
||||||
|
console.log(`[Remote Gateway] Guacd 连接断开。客户端 ID: ${clientId}, 原因: ${reason || '未知'}`);
|
||||||
|
});
|
||||||
|
client.on('error', (err: Error) => {
|
||||||
|
console.error(`[Remote Gateway] Guacd 客户端错误。客户端 ID: ${clientId}, 错误:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Remote Gateway] 初始化 GuacamoleLite 失败:`, error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptToken = (data: string, keyBuffer: Buffer): string => {
|
||||||
|
try {
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv);
|
||||||
|
let encrypted = cipher.update(data, 'utf8', 'base64');
|
||||||
|
encrypted += cipher.final('base64');
|
||||||
|
const output = {
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
value: encrypted
|
||||||
|
};
|
||||||
|
const jsonString = JSON.stringify(output);
|
||||||
|
return Buffer.from(jsonString).toString('base64');
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Remote Gateway] 令牌加密失败:", e);
|
||||||
|
throw new Error("令牌加密失败。");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.post('/api/remote-desktop/token', (req: Request, res: Response): void => {
|
||||||
|
const { protocol, connectionConfig } = req.body;
|
||||||
|
|
||||||
|
if (!protocol || !connectionConfig) {
|
||||||
|
res.status(400).json({ error: '缺少必需的参数 (protocol, connectionConfig)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (protocol !== 'rdp' && protocol !== 'vnc') {
|
||||||
|
res.status(400).json({ error: '无效的协议类型。支持 "rdp" 或 "vnc"。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hostname, port, username, password, width, height, dpi, security, ignoreCert } = connectionConfig;
|
||||||
|
|
||||||
|
if (!hostname || !port) {
|
||||||
|
res.status(400).json({ error: '缺少必需的连接参数 (hostname, port)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: any = {
|
||||||
|
hostname: hostname as string,
|
||||||
|
port: port as string,
|
||||||
|
width: String(width || '1024'),
|
||||||
|
height: String(height || '768'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (protocol === 'rdp') {
|
||||||
|
if (typeof username === 'undefined' || typeof password === 'undefined') {
|
||||||
|
res.status(400).json({ error: 'RDP 连接缺少 username 或 password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.username = username as string;
|
||||||
|
settings.password = password as string;
|
||||||
|
settings.security = security || 'any'; // RDP 特有,使用默认值 'any'
|
||||||
|
settings['ignore-cert'] = String(ignoreCert || 'true'); // RDP 特有
|
||||||
|
settings.dpi = String(dpi || '96'); // RDP 特有
|
||||||
|
} else if (protocol === 'vnc') {
|
||||||
|
if (typeof password === 'undefined') {
|
||||||
|
res.status(400).json({ error: 'VNC 连接缺少 password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.password = password as string;
|
||||||
|
if (username) { // VNC 可选 username
|
||||||
|
settings.username = username as string;
|
||||||
|
}
|
||||||
|
// VNC 特有的其他参数可以根据需要从 connectionConfig 中获取并添加
|
||||||
|
// 例如: settings['enable-audio'] = connectionConfig.enableAudio || 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionParams = {
|
||||||
|
connection: {
|
||||||
|
type: protocol, // 'rdp' or 'vnc'
|
||||||
|
settings: settings
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenData = JSON.stringify(connectionParams);
|
||||||
|
const encryptedToken = encryptToken(tokenData, ENCRYPTION_KEY_BUFFER);
|
||||||
|
res.json({ token: encryptedToken });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Remote Gateway] /api/remote-desktop/token 接口出错:", error);
|
||||||
|
res.status(500).json({ error: '生成令牌失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
apiServer.listen(REMOTE_GATEWAY_API_PORT, () => {
|
||||||
|
console.log(`[Remote Gateway] API 服务器正在监听端口 ${REMOTE_GATEWAY_API_PORT}`);
|
||||||
|
console.log(`[Remote Gateway] Guacamole WebSocket 服务器应在端口 ${REMOTE_GATEWAY_WS_PORT} 上运行 (由 GuacamoleLite 管理)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const gracefulShutdown = (signal: string) => {
|
||||||
|
console.log(`[Remote Gateway] 收到 ${signal} 信号。正在优雅地关闭...`);
|
||||||
|
|
||||||
|
let guacClosed = false;
|
||||||
|
let apiClosed = false;
|
||||||
|
|
||||||
|
const tryExit = () => {
|
||||||
|
if (guacClosed && apiClosed) {
|
||||||
|
console.log("[Remote Gateway] 所有服务器已关闭。正在退出。");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apiServer.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("[Remote Gateway] 关闭 API 服务器时出错:", err);
|
||||||
|
} else {
|
||||||
|
console.log("[Remote Gateway] API 服务器已关闭。");
|
||||||
|
}
|
||||||
|
apiClosed = true;
|
||||||
|
tryExit();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof guacServer !== 'undefined' && guacServer && typeof guacServer.close === 'function') {
|
||||||
|
console.log("[Remote Gateway] 正在关闭 Guacamole 服务器...");
|
||||||
|
guacServer.close(() => {
|
||||||
|
console.log("[Remote Gateway] Guacamole 服务器已关闭。");
|
||||||
|
guacClosed = true;
|
||||||
|
tryExit();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("[Remote Gateway] Guacamole 服务器未运行或不支持 close() 方法。");
|
||||||
|
guacClosed = true;
|
||||||
|
tryExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error("[Remote Gateway] 关闭超时。强制退出。");
|
||||||
|
process.exit(1);
|
||||||
|
}, 10000); // 10 秒超时
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
process.on('SIGUSR2', () => {
|
||||||
|
gracefulShutdown('SIGUSR2 (nodemon restart)');
|
||||||
|
});
|
||||||
@@ -114,4 +114,4 @@
|
|||||||
"src/**/*", // Include all files in the src directory
|
"src/**/*", // Include all files in the src directory
|
||||||
"guacamole-lite.d.ts" // Include the specific .d.ts file
|
"guacamole-lite.d.ts" // Include the specific .d.ts file
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user