This commit is contained in:
Baobhan Sith
2025-04-15 09:03:44 +08:00
parent 4ec2ec869f
commit 099a94f358
13 changed files with 2152 additions and 1267 deletions
@@ -0,0 +1,214 @@
import * as ConnectionRepository from '../repositories/connection.repository';
import { encrypt, decrypt } from '../utils/crypto';
// Re-export or define types needed by the controller/service
// Ideally, these would be in a shared types file, e.g., packages/backend/src/types/connection.types.ts
// For now, let's reuse the interfaces from the repository (adjust as needed)
export interface ConnectionBase {
id: number;
name: string;
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
proxy_id: number | null;
created_at: number;
updated_at: number;
last_connected_at: number | null;
}
export interface ConnectionWithTags extends ConnectionBase {
tag_ids: number[];
}
// Input type for creating a connection (from controller)
export interface CreateConnectionInput {
name: string;
host: string;
port?: number; // Optional, defaults in service/repo
username: string;
auth_method: 'password' | 'key';
password?: string; // Optional depending on auth_method
private_key?: string; // Optional depending on auth_method
passphrase?: string; // Optional for key auth
proxy_id?: number | null;
tag_ids?: number[];
}
// Input type for updating a connection (from controller)
// All fields are optional except potentially auth_method related ones
export interface UpdateConnectionInput {
name?: string;
host?: string;
port?: number;
username?: string;
auth_method?: 'password' | 'key';
password?: string;
private_key?: string;
passphrase?: string; // Use undefined to signal no change, null/empty string to clear
proxy_id?: number | null;
tag_ids?: number[];
}
/**
* 获取所有连接(包含标签)
*/
export const getAllConnections = async (): Promise<ConnectionWithTags[]> => {
return ConnectionRepository.findAllConnectionsWithTags();
};
/**
* 根据 ID 获取单个连接(包含标签)
*/
export const getConnectionById = async (id: number): Promise<ConnectionWithTags | null> => {
return ConnectionRepository.findConnectionByIdWithTags(id);
};
/**
* 创建新连接
*/
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
// 1. Validate input (basic validation, more complex validation can be added)
if (!input.name || !input.host || !input.username || !input.auth_method) {
throw new Error('缺少必要的连接信息 (name, host, username, auth_method)。');
}
if (input.auth_method === 'password' && !input.password) {
throw new Error('密码认证方式需要提供 password。');
}
if (input.auth_method === 'key' && !input.private_key) {
throw new Error('密钥认证方式需要提供 private_key。');
}
// Add more validation as needed (port range, proxy existence etc.)
// 2. Encrypt credentials
let encryptedPassword = null;
let encryptedPrivateKey = null;
let encryptedPassphrase = null;
if (input.auth_method === 'password') {
encryptedPassword = encrypt(input.password!);
} else if (input.auth_method === 'key') {
encryptedPrivateKey = encrypt(input.private_key!);
if (input.passphrase) {
encryptedPassphrase = encrypt(input.passphrase);
}
}
// 3. Prepare data for repository
const connectionData = {
name: input.name,
host: input.host,
port: input.port ?? 22, // Default port
username: input.username,
auth_method: input.auth_method,
encrypted_password: encryptedPassword,
encrypted_private_key: encryptedPrivateKey,
encrypted_passphrase: encryptedPassphrase,
proxy_id: input.proxy_id ?? null,
};
// 4. Create connection record in repository
const newConnectionId = await ConnectionRepository.createConnection(connectionData);
// 5. Handle tags
const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? [];
if (tagIds.length > 0) {
await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds);
}
// 6. Fetch and return the newly created connection with tags
const newConnection = await getConnectionById(newConnectionId);
if (!newConnection) {
// This should ideally not happen if creation was successful
throw new Error('创建连接后无法检索到该连接。');
}
return newConnection;
};
/**
* 更新连接信息
*/
export const updateConnection = async (id: number, input: UpdateConnectionInput): Promise<ConnectionWithTags | null> => {
// 1. Fetch current connection data (including encrypted fields) to compare
const currentFullConnection = await ConnectionRepository.findFullConnectionById(id);
if (!currentFullConnection) {
return null; // Connection not found
}
// 2. Prepare data for update
const dataToUpdate: Partial<ConnectionRepository.FullConnectionData> = {};
let needsCredentialUpdate = false;
let newAuthMethod = input.auth_method || currentFullConnection.auth_method;
// Update non-credential fields
if (input.name !== undefined) dataToUpdate.name = input.name;
if (input.host !== undefined) dataToUpdate.host = input.host;
if (input.port !== undefined) dataToUpdate.port = input.port;
if (input.username !== undefined) dataToUpdate.username = input.username;
if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id; // Allows setting to null
// Handle auth method change or credential update
if (input.auth_method && input.auth_method !== currentFullConnection.auth_method) {
// Auth method changed
dataToUpdate.auth_method = input.auth_method;
needsCredentialUpdate = true;
if (input.auth_method === 'password') {
if (!input.password) throw new Error('切换到密码认证时需要提供 password。');
dataToUpdate.encrypted_password = encrypt(input.password);
dataToUpdate.encrypted_private_key = null;
dataToUpdate.encrypted_passphrase = null;
} else { // key
if (!input.private_key) throw new Error('切换到密钥认证时需要提供 private_key。');
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
dataToUpdate.encrypted_password = null;
}
} else {
// Auth method did not change, check if credentials for the current method were provided
if (newAuthMethod === 'password' && input.password !== undefined) {
dataToUpdate.encrypted_password = encrypt(input.password);
needsCredentialUpdate = true;
} else if (newAuthMethod === 'key') {
if (input.private_key !== undefined) {
dataToUpdate.encrypted_private_key = encrypt(input.private_key);
// Passphrase must be updated (or cleared) if private key is updated
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
needsCredentialUpdate = true;
} else if (input.passphrase !== undefined) { // Only passphrase provided
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
needsCredentialUpdate = true;
}
}
}
// 3. Update connection record if there are changes
const hasNonTagChanges = Object.keys(dataToUpdate).length > 0;
if (hasNonTagChanges) {
const updated = await ConnectionRepository.updateConnection(id, dataToUpdate);
if (!updated) {
// Should not happen if findFullConnectionById succeeded, but good practice
throw new Error('更新连接记录失败。');
}
}
// 4. Handle tags update if tag_ids were provided
if (input.tag_ids !== undefined) {
const validTagIds = input.tag_ids.filter(tagId => typeof tagId === 'number' && tagId > 0);
await ConnectionRepository.updateConnectionTags(id, validTagIds);
}
// 5. Fetch and return the updated connection
return getConnectionById(id);
};
/**
* 删除连接
*/
export const deleteConnection = async (id: number): Promise<boolean> => {
return ConnectionRepository.deleteConnection(id);
};
// Note: testConnection, importConnections, exportConnections logic
// will be moved to SshService and ImportExportService respectively.
@@ -0,0 +1,291 @@
import * as ConnectionRepository from '../repositories/connection.repository';
import * as ProxyRepository from '../repositories/proxy.repository';
import { getDb } from '../database'; // Need db instance for transaction
const db = getDb(); // Get db instance for transaction management
// Define structure for imported connection data (can be shared in types)
interface ImportedConnectionData {
name: string;
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
encrypted_password?: string | null;
encrypted_private_key?: string | null;
encrypted_passphrase?: string | null;
tag_ids?: number[];
proxy?: {
name: string;
type: 'SOCKS5' | 'HTTP';
host: string;
port: number;
username?: string | null;
auth_method?: 'none' | 'password' | 'key';
encrypted_password?: string | null;
encrypted_private_key?: string | null;
encrypted_passphrase?: string | null;
} | null;
}
// Define structure for exported connection data (can be shared in types)
interface ExportedConnectionData extends Omit<ImportedConnectionData, 'id'> {
// Exclude fields not needed for export like id, created_at etc.
}
// Define structure for import results
export interface ImportResult {
successCount: number;
failureCount: number;
errors: { connectionName?: string; message: string }[];
}
/**
* 导出所有连接配置
*/
export const exportConnections = async (): Promise<ExportedConnectionData[]> => {
// 1. Fetch all connections with tags (basic info)
// We need full connection info including encrypted fields and proxy details for export
// Let's adapt the repository or add a new method if needed.
// For now, let's assume findFullConnectionById can be adapted or a similar findAll method exists.
// Re-using the logic from controller for now, ideally repo handles joins.
const connectionsWithProxies = await new Promise<any[]>((resolve, reject) => {
db.all(
`SELECT
c.id, c.name, c.host, c.port, c.username, c.auth_method,
c.encrypted_password, c.encrypted_private_key, c.encrypted_passphrase,
c.proxy_id,
p.id as proxy_db_id, p.name as proxy_name, p.type as proxy_type,
p.host as proxy_host, p.port as proxy_port, p.username as proxy_username,
p.auth_method as proxy_auth_method,
p.encrypted_password as proxy_encrypted_password,
p.encrypted_private_key as proxy_encrypted_private_key,
p.encrypted_passphrase as proxy_encrypted_passphrase
FROM connections c
LEFT JOIN proxies p ON c.proxy_id = p.id
ORDER BY c.name ASC`,
(err, rows: any[]) => {
if (err) {
console.error('Service: 查询连接和代理信息以供导出时出错:', err.message);
return reject(new Error('导出连接失败:查询连接信息出错'));
}
resolve(rows);
}
);
});
const connectionTags = await new Promise<{[connId: number]: number[]}>((resolve, reject) => {
db.all('SELECT connection_id, tag_id FROM connection_tags', (err, rows: {connection_id: number, tag_id: number}[]) => {
if (err) {
console.error('Service: 查询连接标签以供导出时出错:', err.message);
return reject(new Error('导出连接失败:查询标签信息出错'));
}
const tagsMap: {[connId: number]: number[]} = {};
rows.forEach(row => {
if (!tagsMap[row.connection_id]) tagsMap[row.connection_id] = [];
tagsMap[row.connection_id].push(row.tag_id);
});
resolve(tagsMap);
});
});
// 2. Format data for export
const formattedData: ExportedConnectionData[] = connectionsWithProxies.map(row => {
const connection: ExportedConnectionData = {
name: row.name,
host: row.host,
port: row.port,
username: row.username,
auth_method: row.auth_method,
encrypted_password: row.encrypted_password,
encrypted_private_key: row.encrypted_private_key,
encrypted_passphrase: row.encrypted_passphrase,
tag_ids: connectionTags[row.id] || [],
proxy: null // Initialize proxy as null
};
if (row.proxy_db_id) {
connection.proxy = {
name: row.proxy_name,
type: row.proxy_type,
host: row.proxy_host,
port: row.proxy_port,
username: row.proxy_username,
auth_method: row.proxy_auth_method,
encrypted_password: row.proxy_encrypted_password,
encrypted_private_key: row.proxy_encrypted_private_key,
encrypted_passphrase: row.proxy_encrypted_passphrase,
};
}
return connection;
});
return formattedData;
};
/**
* 导入连接配置
* @param fileBuffer Buffer containing the JSON file content
*/
export const importConnections = async (fileBuffer: Buffer): Promise<ImportResult> => {
let importedData: ImportedConnectionData[];
try {
const fileContent = fileBuffer.toString('utf8');
importedData = JSON.parse(fileContent);
if (!Array.isArray(importedData)) {
throw new Error('JSON 文件内容必须是一个数组。');
}
} catch (error: any) {
console.error('Service: 解析导入文件失败:', error);
throw new Error(`解析 JSON 文件失败: ${error.message}`); // Re-throw for controller
}
let successCount = 0;
let failureCount = 0;
const errors: { connectionName?: string; message: string }[] = [];
const connectionsToInsert: Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'>[] = [];
// Use a transaction for atomicity
return new Promise<ImportResult>((resolveOuter, rejectOuter) => {
db.serialize(() => {
db.run('BEGIN TRANSACTION', async (beginErr: Error | null) => {
if (beginErr) {
console.error('Service: 开始导入事务失败:', beginErr);
return rejectOuter(new Error(`开始事务失败: ${beginErr.message}`));
}
try {
// Process each connection data from the imported file
for (const connData of importedData) {
try {
// 1. Validate connection data (basic)
if (!connData.name || !connData.host || !connData.port || !connData.username || !connData.auth_method) {
throw new Error('缺少必要的连接字段 (name, host, port, username, auth_method)。');
}
if (connData.auth_method === 'password' && !connData.encrypted_password) {
throw new Error('密码认证缺少 encrypted_password。');
}
if (connData.auth_method === 'key' && !connData.encrypted_private_key) {
throw new Error('密钥认证缺少 encrypted_private_key。');
}
// Add more validation as needed
let proxyIdToUse: number | null = null;
// 2. Handle proxy (find or create)
if (connData.proxy) {
const proxyData = connData.proxy;
// Validate proxy data
if (!proxyData.name || !proxyData.type || !proxyData.host || !proxyData.port) {
throw new Error('代理信息不完整 (缺少 name, type, host, port)。');
}
// Add more proxy validation if needed
// Try to find existing proxy
const existingProxy = await ProxyRepository.findProxyByNameTypeHostPort(proxyData.name, proxyData.type, proxyData.host, proxyData.port);
if (existingProxy) {
proxyIdToUse = existingProxy.id;
} else {
// Proxy doesn't exist, create it
const newProxyData = {
name: proxyData.name,
type: proxyData.type,
host: proxyData.host,
port: proxyData.port,
username: proxyData.username || null,
auth_method: proxyData.auth_method || 'none',
encrypted_password: proxyData.encrypted_password || null,
encrypted_private_key: proxyData.encrypted_private_key || null,
encrypted_passphrase: proxyData.encrypted_passphrase || null,
};
proxyIdToUse = await ProxyRepository.createProxy(newProxyData);
console.log(`Service: 导入连接 ${connData.name}: 新代理 ${proxyData.name} 创建成功 (ID: ${proxyIdToUse})`);
}
}
// 3. Prepare connection data for bulk insert
connectionsToInsert.push({
name: connData.name,
host: connData.host,
port: connData.port,
username: connData.username,
auth_method: connData.auth_method,
encrypted_password: connData.encrypted_password || null,
encrypted_private_key: connData.encrypted_private_key || null,
encrypted_passphrase: connData.encrypted_passphrase || null,
proxy_id: proxyIdToUse,
// tag_ids will be handled separately after insertion
});
} catch (connError: any) {
// Error processing this specific connection
failureCount++;
errors.push({ connectionName: connData.name || '未知连接', message: connError.message });
console.warn(`Service: 处理导入连接 "${connData.name || '未知'}" 时出错: ${connError.message}`);
}
} // End for loop
// 4. Bulk insert connections
let insertedResults: { connectionId: number, originalData: any }[] = [];
if (connectionsToInsert.length > 0) {
insertedResults = await ConnectionRepository.bulkInsertConnections(connectionsToInsert);
successCount = insertedResults.length;
}
// 5. Associate tags for successfully inserted connections
for (const result of insertedResults) {
const originalTagIds = result.originalData?.tag_ids;
if (Array.isArray(originalTagIds) && originalTagIds.length > 0) {
const validTagIds = originalTagIds.filter((id: any) => typeof id === 'number' && id > 0);
if (validTagIds.length > 0) {
try {
await ConnectionRepository.updateConnectionTags(result.connectionId, validTagIds);
} catch (tagError: any) {
// Log warning but don't fail the entire import for tag association error
console.warn(`Service: 导入连接 ${result.originalData.name}: 关联标签失败 (ID: ${result.connectionId}): ${tagError.message}`);
// Optionally, add this to the 'errors' array reported back
errors.push({ connectionName: result.originalData.name, message: `关联标签失败: ${tagError.message}` });
// Decrement successCount or increment failureCount if tag failure should count as overall failure
// failureCount++; // Example: Count tag failures
}
}
}
}
// 6. Commit or Rollback
if (failureCount > 0 && successCount === 0) { // Only rollback if ALL fail, or adjust logic as needed
console.warn(`Service: 导入连接存在 ${failureCount} 个错误,且无成功记录,正在回滚事务...`);
db.run('ROLLBACK', (rollbackErr: Error | null) => {
if (rollbackErr) console.error("Service: 回滚事务失败:", rollbackErr);
// Reject outer promise with collected errors
rejectOuter(new Error(`导入失败,存在 ${failureCount} 个错误。`));
});
} else {
// Commit even if some failed, report partial success
db.run('COMMIT', (commitErr: Error | null) => {
if (commitErr) {
console.error('Service: 提交导入事务时出错:', commitErr);
rejectOuter(new Error(`提交导入事务失败: ${commitErr.message}`));
} else {
console.log(`Service: 导入事务提交。成功: ${successCount}, 失败: ${failureCount}`);
resolveOuter({ successCount, failureCount, errors }); // Resolve outer promise
}
});
}
} catch (innerError: any) {
// Catch errors during the process (e.g., bulk insert failure)
console.error('Service: 导入事务内部出错:', innerError);
db.run('ROLLBACK', (rollbackErr: Error | null) => {
if (rollbackErr) console.error("Service: 回滚事务失败:", rollbackErr);
rejectOuter(innerError); // Reject outer promise
});
}
}); // End BEGIN TRANSACTION
}); // End db.serialize
}); // End new Promise
};
@@ -0,0 +1,174 @@
import * as ProxyRepository from '../repositories/proxy.repository';
import { encrypt, decrypt } from '../utils/crypto'; // Assuming crypto utils are needed
// Re-export or define types (ideally from a shared types file)
export interface ProxyData extends ProxyRepository.ProxyData {}
// Input type for creating a proxy
export interface CreateProxyInput {
name: string;
type: 'SOCKS5' | 'HTTP';
host: string;
port: number;
username?: string | null;
auth_method?: 'none' | 'password' | 'key'; // Optional, defaults to 'none'
password?: string | null; // Plain text password
private_key?: string | null; // Plain text private key
passphrase?: string | null; // Plain text passphrase
}
// Input type for updating a proxy
export interface UpdateProxyInput {
name?: string;
type?: 'SOCKS5' | 'HTTP';
host?: string;
port?: number;
username?: string | null;
auth_method?: 'none' | 'password' | 'key';
password?: string | null; // Use undefined for no change, null/empty to clear
private_key?: string | null; // Use undefined for no change, null/empty to clear
passphrase?: string | null; // Use undefined for no change, null/empty to clear
}
/**
* 获取所有代理
*/
export const getAllProxies = async (): Promise<ProxyData[]> => {
// Repository returns data with encrypted fields, which is fine for listing generally
// If decryption is needed for display, it should happen closer to the presentation layer or selectively
return ProxyRepository.findAllProxies();
};
/**
* 根据 ID 获取单个代理
*/
export const getProxyById = async (id: number): Promise<ProxyData | null> => {
// Repository returns data with encrypted fields
return ProxyRepository.findProxyById(id);
};
/**
* 创建新代理
*/
export const createProxy = async (input: CreateProxyInput): Promise<ProxyData> => {
// 1. Validate input
if (!input.name || !input.type || !input.host || !input.port) {
throw new Error('缺少必要的代理信息 (name, type, host, port)。');
}
if (input.auth_method === 'password' && !input.password) {
throw new Error('代理密码认证方式需要提供 password。');
}
if (input.auth_method === 'key' && !input.private_key) {
throw new Error('代理密钥认证方式需要提供 private_key。');
}
// Add more validation (port range, type check etc.)
// 2. Encrypt credentials if provided
const encryptedPassword = input.password ? encrypt(input.password) : null;
const encryptedPrivateKey = input.private_key ? encrypt(input.private_key) : null;
const encryptedPassphrase = input.passphrase ? encrypt(input.passphrase) : null;
// 3. Prepare data for repository
const proxyData: Omit<ProxyData, 'id' | 'created_at' | 'updated_at'> = {
name: input.name,
type: input.type,
host: input.host,
port: input.port,
username: input.username || null,
auth_method: input.auth_method || 'none',
encrypted_password: encryptedPassword,
encrypted_private_key: encryptedPrivateKey,
encrypted_passphrase: encryptedPassphrase,
};
// 4. Create proxy record
const newProxyId = await ProxyRepository.createProxy(proxyData);
// 5. Fetch and return the newly created proxy
const newProxy = await getProxyById(newProxyId);
if (!newProxy) {
throw new Error('创建代理后无法检索到该代理。');
}
return newProxy;
};
/**
* 更新代理信息
*/
export const updateProxy = async (id: number, input: UpdateProxyInput): Promise<ProxyData | null> => {
// 1. Fetch current proxy data to compare if needed (e.g., for auth method change logic)
const currentProxy = await ProxyRepository.findProxyById(id);
if (!currentProxy) {
return null; // Proxy not found
}
// 2. Prepare data for update
const dataToUpdate: Partial<Omit<ProxyData, 'id' | 'created_at'>> = {};
let needsCredentialUpdate = false;
const newAuthMethod = input.auth_method || currentProxy.auth_method;
// Update standard fields
if (input.name !== undefined) dataToUpdate.name = input.name;
if (input.type !== undefined) dataToUpdate.type = input.type;
if (input.host !== undefined) dataToUpdate.host = input.host;
if (input.port !== undefined) dataToUpdate.port = input.port;
if (input.username !== undefined) dataToUpdate.username = input.username; // Allows clearing
// Handle auth method change or credential update
if (input.auth_method && input.auth_method !== currentProxy.auth_method) {
dataToUpdate.auth_method = input.auth_method;
needsCredentialUpdate = true;
// Encrypt new credentials based on the *new* auth_method
if (input.auth_method === 'password') {
if (input.password === undefined) throw new Error('切换到密码认证时需要提供 password。');
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
dataToUpdate.encrypted_private_key = null; // Clear old key info
dataToUpdate.encrypted_passphrase = null;
} else if (input.auth_method === 'key') {
if (input.private_key === undefined) throw new Error('切换到密钥认证时需要提供 private_key。');
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
dataToUpdate.encrypted_password = null; // Clear old password info
} else { // 'none'
dataToUpdate.encrypted_password = null;
dataToUpdate.encrypted_private_key = null;
dataToUpdate.encrypted_passphrase = null;
}
} else {
// Auth method unchanged, update credentials if provided for the current method
if (newAuthMethod === 'password' && input.password !== undefined) {
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
needsCredentialUpdate = true;
} else if (newAuthMethod === 'key') {
if (input.private_key !== undefined) {
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null; // Update passphrase together
needsCredentialUpdate = true;
} else if (input.passphrase !== undefined) { // Only passphrase updated
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
needsCredentialUpdate = true;
}
}
}
// 3. Update proxy record if there are changes
const hasChanges = Object.keys(dataToUpdate).length > 0;
if (hasChanges) {
const updated = await ProxyRepository.updateProxy(id, dataToUpdate);
if (!updated) {
throw new Error('更新代理记录失败。');
}
}
// 4. Fetch and return the updated proxy
return getProxyById(id);
};
/**
* 删除代理
*/
export const deleteProxy = async (id: number): Promise<boolean> => {
// Repository handles setting foreign keys to NULL in connections table
return ProxyRepository.deleteProxy(id);
};
@@ -0,0 +1,186 @@
import { Client, SFTPWrapper, Stats, Dirent } from 'ssh2'; // 导入 Stats 和 Dirent 类型
import { WebSocket } from 'ws';
// import { logger } from '../utils/logger'; // 不再使用自定义 logger,改用 console
// 定义客户端状态接口
interface ClientState {
ws: WebSocket;
sshClient: Client;
sftp?: SFTPWrapper;
// 如果需要,可以添加其他相关的状态属性
}
export class SftpService {
private clientStates: Map<string, ClientState>; // 存储 connectionId 到 ClientState 的映射
constructor(clientStates: Map<string, ClientState>) {
this.clientStates = clientStates;
}
/**
* 初始化 SFTP 会话
* @param connectionId 连接 ID
*/
async initializeSftpSession(connectionId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sshClient || state.sftp) {
console.warn(`[SFTP] 无法为 ${connectionId} 初始化 SFTP:状态无效或 SFTP 已初始化。`);
return;
}
return new Promise((resolve, reject) => {
state.sshClient.sftp((err, sftp) => {
if (err) {
console.error(`[SFTP] 为 ${connectionId} 初始化 SFTP 会话失败:`, err);
state.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, message: 'SFTP 初始化失败' } }));
reject(err);
} else {
console.log(`[SFTP] 为 ${connectionId} 初始化 SFTP 会话成功。`);
state.sftp = sftp;
state.ws.send(JSON.stringify({ type: 'sftp_ready', payload: { connectionId } }));
sftp.on('end', () => {
console.log(`[SFTP] ${connectionId} 的 SFTP 会话已结束。`);
if (state) state.sftp = undefined; // 在结束时清除 SFTP 实例
});
sftp.on('close', () => {
console.log(`[SFTP] ${connectionId} 的 SFTP 会话已关闭。`);
if (state) state.sftp = undefined; // 在关闭时清除 SFTP 实例
});
sftp.on('error', (sftpErr: Error) => { // 为 sftpErr 添加 Error 类型
console.error(`[SFTP] ${connectionId} 的 SFTP 会话出错:`, sftpErr);
if (state) state.sftp = undefined; // 在出错时清除 SFTP 实例
// 可选:通知客户端
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, message: 'SFTP 会话错误' } }));
});
resolve();
}
});
});
}
cleanupSftpSession(connectionId: string): void {
const state = this.clientStates.get(connectionId);
if (state?.sftp) {
logger.info(`[SFTP] Cleaning up SFTP session for ${connectionId}`);
state.sftp.end();
state.sftp = undefined;
}
}
// Placeholder methods for SFTP operations - to be implemented
async readdir(connectionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for readdir on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
// Implementation to follow
logger.debug(`[SFTP] Received readdir request for ${connectionId}:${path}`);
// Example: state.sftp.readdir(...)
state.ws.send(JSON.stringify({ type: 'sftp_readdir_result', payload: { connectionId, requestId, files: [] /* Placeholder */ } }));
}
async stat(connectionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for stat on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
logger.debug(`[SFTP] Received stat request for ${connectionId}:${path}`);
// Implementation to follow
state.ws.send(JSON.stringify({ type: 'sftp_stat_result', payload: { connectionId, requestId, stats: null /* Placeholder */ } }));
}
async readFile(connectionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for readFile on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
logger.debug(`[SFTP] Received readFile request for ${connectionId}:${path}`);
// Implementation to follow
state.ws.send(JSON.stringify({ type: 'sftp_readfile_result', payload: { connectionId, requestId, data: '' /* Placeholder */ } }));
}
async writeFile(connectionId: string, path: string, data: string, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for writeFile on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
logger.debug(`[SFTP] Received writeFile request for ${connectionId}:${path}`);
// Implementation to follow
state.ws.send(JSON.stringify({ type: 'sftp_writefile_result', payload: { connectionId, requestId, success: false /* Placeholder */ } }));
}
async mkdir(connectionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for mkdir on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
logger.debug(`[SFTP] Received mkdir request for ${connectionId}:${path}`);
// Implementation to follow
state.ws.send(JSON.stringify({ type: 'sftp_mkdir_result', payload: { connectionId, requestId, success: false /* Placeholder */ } }));
}
async rmdir(connectionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for rmdir on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
logger.debug(`[SFTP] Received rmdir request for ${connectionId}:${path}`);
// Implementation to follow
state.ws.send(JSON.stringify({ type: 'sftp_rmdir_result', payload: { connectionId, requestId, success: false /* Placeholder */ } }));
}
async unlink(connectionId: string, path: string, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for unlink on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
logger.debug(`[SFTP] Received unlink request for ${connectionId}:${path}`);
// Implementation to follow
state.ws.send(JSON.stringify({ type: 'sftp_unlink_result', payload: { connectionId, requestId, success: false /* Placeholder */ } }));
}
async rename(connectionId: string, oldPath: string, newPath: string, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for rename on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
logger.debug(`[SFTP] Received rename request for ${connectionId}: ${oldPath} -> ${newPath}`);
// Implementation to follow
state.ws.send(JSON.stringify({ type: 'sftp_rename_result', payload: { connectionId, requestId, success: false /* Placeholder */ } }));
}
async chmod(connectionId: string, path: string, mode: number, requestId: string): Promise<void> {
const state = this.clientStates.get(connectionId);
if (!state || !state.sftp) {
logger.warn(`[SFTP] SFTP not ready for chmod on ${connectionId}`);
state?.ws.send(JSON.stringify({ type: 'sftp_error', payload: { connectionId, requestId, message: 'SFTP session not ready' } }));
return;
}
logger.debug(`[SFTP] Received chmod request for ${connectionId}:${path} to mode ${mode.toString(8)}`);
// Implementation to follow
state.ws.send(JSON.stringify({ type: 'sftp_chmod_result', payload: { connectionId, requestId, success: false /* Placeholder */ } }));
}
// TODO: Implement file upload/download logic with progress reporting
// async uploadFile(...)
// async downloadFile(...)
}
@@ -0,0 +1,433 @@
import { Client, ClientChannel, ConnectConfig } from 'ssh2'; // Import ClientChannel and ConnectConfig
import { SocksClient, SocksClientOptions } from 'socks'; // Import SocksClientOptions
import http from 'http';
import net from 'net';
import WebSocket from 'ws'; // Import WebSocket for type hint
import * as ConnectionRepository from '../repositories/connection.repository';
import { decrypt } from '../utils/crypto';
// Import SftpService if needed later for initialization
// import * as SftpService from './sftp.service';
// Import StatusMonitorService if needed later for initialization
// import * as StatusMonitorService from './status-monitor.service';
const CONNECT_TIMEOUT = 20000; // 连接超时时间 (毫秒)
const TEST_TIMEOUT = 15000; // 测试连接超时时间 (毫秒)
// Define AuthenticatedWebSocket interface (or import from websocket.ts if refactored there)
// This is needed to associate SSH clients with specific WS connections
interface AuthenticatedWebSocket extends WebSocket {
isAlive?: boolean;
userId?: number;
username?: string;
// sshClient?: Client; // Managed by the service now
// sshShellStream?: ClientChannel; // Managed by the service now
}
// Structure to hold active SSH connection details managed by this service
interface ActiveSshSession {
client: Client;
shell: ClientChannel;
// sftp?: SFTPWrapper; // SFTP will be managed by SftpService
// statusIntervalId?: NodeJS.Timeout; // Status polling managed by StatusMonitorService
connectionInfo: DecryptedConnectionDetails; // Store connection info for context (Fix typo)
}
// Map to store active sessions associated with WebSocket clients
const activeSessions = new Map<AuthenticatedWebSocket, ActiveSshSession>();
// 辅助接口:定义解密后的凭证和代理信息结构 (可以共享到 types 文件)
// Renamed to avoid conflict if imported later
interface DecryptedConnectionDetails {
id: number;
name: string;
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
password?: string; // Decrypted
privateKey?: string; // Decrypted
passphrase?: string; // Decrypted
proxy?: {
id: number;
name: string;
type: 'SOCKS5' | 'HTTP';
host: string;
port: number;
username?: string;
password?: string; // Decrypted
auth_method?: string; // Proxy auth method
privateKey?: string; // Decrypted proxy key
passphrase?: string; // Decrypted proxy passphrase
} | null;
}
/**
* 测试给定 ID 的 SSH 连接(包括代理)
* @param connectionId 连接 ID
* @returns Promise<void> - 如果连接成功则 resolve,否则 reject
* @throws Error 如果连接失败或配置错误
*/
export const testConnection = async (connectionId: number): Promise<void> => {
console.log(`SshService: Testing connection ${connectionId}...`);
// 1. 获取完整的连接信息(包括加密凭证和代理信息)
const rawConnInfo = await ConnectionRepository.findFullConnectionById(connectionId); // Assuming this fetches proxy details too
if (!rawConnInfo) {
throw new Error('连接配置未找到。');
}
// 2. 解密凭证并构建结构化的连接信息
let fullConnInfo: DecryptedConnectionDetails;
try {
fullConnInfo = {
id: rawConnInfo.id,
name: rawConnInfo.name,
host: rawConnInfo.host,
port: rawConnInfo.port,
username: rawConnInfo.username,
auth_method: rawConnInfo.auth_method,
password: (rawConnInfo.auth_method === 'password' && rawConnInfo.encrypted_password) ? decrypt(rawConnInfo.encrypted_password) : undefined,
privateKey: (rawConnInfo.auth_method === 'key' && rawConnInfo.encrypted_private_key) ? decrypt(rawConnInfo.encrypted_private_key) : undefined,
passphrase: (rawConnInfo.auth_method === 'key' && rawConnInfo.encrypted_passphrase) ? decrypt(rawConnInfo.encrypted_passphrase) : undefined,
proxy: null,
};
if (rawConnInfo.proxy_db_id) {
fullConnInfo.proxy = {
id: rawConnInfo.proxy_db_id,
name: rawConnInfo.proxy_name,
type: rawConnInfo.proxy_type,
host: rawConnInfo.proxy_host,
port: rawConnInfo.proxy_port,
username: rawConnInfo.proxy_username || undefined,
auth_method: rawConnInfo.proxy_auth_method, // Include proxy auth method
password: rawConnInfo.proxy_encrypted_password ? decrypt(rawConnInfo.proxy_encrypted_password) : undefined,
privateKey: rawConnInfo.proxy_encrypted_private_key ? decrypt(rawConnInfo.proxy_encrypted_private_key) : undefined, // Decrypt proxy key
passphrase: rawConnInfo.proxy_encrypted_passphrase ? decrypt(rawConnInfo.proxy_encrypted_passphrase) : undefined, // Decrypt proxy passphrase
};
}
} catch (decryptError: any) {
console.error(`Service: 处理连接 ${connectionId} 凭证或代理凭证失败:`, decryptError);
throw new Error(`处理凭证失败: ${decryptError.message}`);
}
// 3. 构建 ssh2 连接配置
const connectConfig: ConnectConfig = { // Use ConnectConfig type
host: fullConnInfo.host,
port: fullConnInfo.port,
username: fullConnInfo.username,
password: fullConnInfo.password,
privateKey: fullConnInfo.privateKey,
passphrase: fullConnInfo.passphrase,
readyTimeout: TEST_TIMEOUT,
keepaliveInterval: 0, // 测试连接不需要 keepalive
};
// 4. 应用代理配置并执行连接 (Refactored into helper)
const sshClient = new Client();
try {
await establishSshConnection(sshClient, connectConfig, fullConnInfo.proxy); // Use helper
console.log(`SshService: Test connection ${connectionId} successful.`);
// Test successful, void promise resolves implicitly
} catch (error) {
console.error(`SshService: Test connection ${connectionId} failed:`, error);
throw error; // Re-throw the specific error
} finally {
// 无论成功失败,都关闭 SSH 客户端
sshClient.end();
console.log(`SshService: Test connection ${connectionId} client closed.`);
}
};
// --- NEW FUNCTIONS FOR MANAGING LIVE CONNECTIONS ---
/**
* Establishes an SSH connection, handling proxies.
* Internal helper function.
* @param sshClient - The ssh2 Client instance.
* @param connectConfig - Base SSH connection config.
* @param proxyInfo - Optional proxy details.
* @returns Promise that resolves when SSH is ready, or rejects on error.
*/
const establishSshConnection = (
sshClient: Client,
connectConfig: ConnectConfig,
proxyInfo: DecryptedConnectionDetails['proxy']
): Promise<void> => {
return new Promise((resolve, reject) => {
const readyHandler = () => {
sshClient.removeListener('error', errorHandler); // Clean up error listener on success
resolve();
};
const errorHandler = (err: Error) => {
sshClient.removeListener('ready', readyHandler); // Clean up ready listener on error
reject(err); // Reject with the specific error
};
sshClient.once('ready', readyHandler);
sshClient.once('error', errorHandler); // Generic error handler for direct connect issues
if (proxyInfo) {
const proxy = proxyInfo;
console.log(`SshService: Applying proxy ${proxy.name} (${proxy.type})`);
if (proxy.type === 'SOCKS5') {
const socksOptions: SocksClientOptions = {
proxy: { host: proxy.host, port: proxy.port, type: 5, userId: proxy.username, password: proxy.password }, // Type 5 is implicit
command: 'connect',
destination: { host: connectConfig.host!, port: connectConfig.port! }, // Use base config host/port
timeout: connectConfig.readyTimeout ?? CONNECT_TIMEOUT, // Use connection timeout
};
SocksClient.createConnection(socksOptions)
.then(({ socket }) => {
console.log(`SshService: SOCKS5 proxy connection successful.`);
connectConfig.sock = socket;
sshClient.connect(connectConfig); // Connect SSH via proxy socket
})
.catch(socksError => {
console.error(`SshService: SOCKS5 proxy connection failed:`, socksError);
// Reject the main promise, remove listeners handled by errorHandler
errorHandler(new Error(`SOCKS5 代理连接失败: ${socksError.message}`));
});
} else if (proxy.type === 'HTTP') {
console.log(`SshService: Attempting HTTP proxy tunnel via ${proxy.host}:${proxy.port}...`);
const reqOptions: http.RequestOptions = { method: 'CONNECT', host: proxy.host, port: proxy.port, path: `${connectConfig.host}:${connectConfig.port}`, timeout: connectConfig.readyTimeout ?? CONNECT_TIMEOUT, agent: false };
if (proxy.username) {
const auth = 'Basic ' + Buffer.from(proxy.username + ':' + (proxy.password || '')).toString('base64');
reqOptions.headers = { ...reqOptions.headers, 'Proxy-Authorization': auth, 'Proxy-Connection': 'Keep-Alive', 'Host': `${connectConfig.host}:${connectConfig.port}` };
}
const req = http.request(reqOptions);
req.on('connect', (res, socket, head) => {
if (res.statusCode === 200) {
console.log(`SshService: HTTP proxy tunnel established.`);
connectConfig.sock = socket;
sshClient.connect(connectConfig); // Connect SSH via tunnel socket
} else {
console.error(`SshService: HTTP proxy CONNECT request failed, status code: ${res.statusCode}`);
socket.destroy();
errorHandler(new Error(`HTTP 代理连接失败 (状态码: ${res.statusCode})`)); // Reject main promise
} // <-- Added missing closing parenthesis here
});
req.on('error', (err) => {
console.error(`SshService: HTTP proxy request error:`, err);
errorHandler(new Error(`HTTP 代理连接错误: ${err.message}`)); // Reject main promise
});
req.on('timeout', () => {
console.error(`SshService: HTTP proxy request timeout.`);
req.destroy();
errorHandler(new Error('HTTP 代理连接超时')); // Reject main promise
});
req.end(); // Send the CONNECT request
} else {
errorHandler(new Error(`不支持的代理类型: ${proxy.type}`)); // Reject main promise
}
} else {
// No proxy, connect directly
console.log(`SshService: No proxy detected, connecting directly...`);
sshClient.connect(connectConfig);
}
});
};
/**
* Connects to SSH, opens a shell, and sets up event forwarding via WebSocket.
* @param connectionId - The ID of the connection config in the database.
* @param ws - The authenticated WebSocket client instance.
*/
export const connectAndOpenShell = async (connectionId: number, ws: AuthenticatedWebSocket): Promise<void> => {
console.log(`SshService: User ${ws.username} requested connection to ID: ${connectionId}`);
if (activeSessions.has(ws)) {
console.warn(`SshService: User ${ws.username} already has an active session.`);
throw new Error('已存在活动的 SSH 连接。');
}
ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在获取连接信息...' }));
// 1. Get connection info
const rawConnInfo = await ConnectionRepository.findFullConnectionById(connectionId);
if (!rawConnInfo) {
throw new Error('连接配置未找到。');
}
// 2. Decrypt and prepare connection details
let fullConnInfo: DecryptedConnectionDetails;
try {
// (Decryption logic similar to testConnection, could be refactored)
fullConnInfo = { /* ... decryption ... */
id: rawConnInfo.id, name: rawConnInfo.name, host: rawConnInfo.host, port: rawConnInfo.port, username: rawConnInfo.username, auth_method: rawConnInfo.auth_method,
password: (rawConnInfo.auth_method === 'password' && rawConnInfo.encrypted_password) ? decrypt(rawConnInfo.encrypted_password) : undefined,
privateKey: (rawConnInfo.auth_method === 'key' && rawConnInfo.encrypted_private_key) ? decrypt(rawConnInfo.encrypted_private_key) : undefined,
passphrase: (rawConnInfo.auth_method === 'key' && rawConnInfo.encrypted_passphrase) ? decrypt(rawConnInfo.encrypted_passphrase) : undefined,
proxy: null,
};
if (rawConnInfo.proxy_db_id) {
fullConnInfo.proxy = { /* ... proxy decryption ... */
id: rawConnInfo.proxy_db_id, name: rawConnInfo.proxy_name, type: rawConnInfo.proxy_type, host: rawConnInfo.proxy_host, port: rawConnInfo.proxy_port, username: rawConnInfo.proxy_username || undefined, auth_method: rawConnInfo.proxy_auth_method,
password: rawConnInfo.proxy_encrypted_password ? decrypt(rawConnInfo.proxy_encrypted_password) : undefined,
privateKey: rawConnInfo.proxy_encrypted_private_key ? decrypt(rawConnInfo.proxy_encrypted_private_key) : undefined,
passphrase: rawConnInfo.proxy_encrypted_passphrase ? decrypt(rawConnInfo.proxy_encrypted_passphrase) : undefined,
};
}
} catch (decryptError: any) {
console.error(`SshService: Handling credentials failed for ${connectionId}:`, decryptError);
throw new Error(`无法处理连接凭证: ${decryptError.message}`);
}
// 3. Prepare SSH config
const connectConfig: ConnectConfig = {
host: fullConnInfo.host,
port: fullConnInfo.port,
username: fullConnInfo.username,
password: fullConnInfo.password,
privateKey: fullConnInfo.privateKey,
passphrase: fullConnInfo.passphrase,
readyTimeout: CONNECT_TIMEOUT,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
};
// 4. Establish connection and open shell
const sshClient = new Client();
// Generic error/close handlers for the client
const clientCloseHandler = () => {
console.log(`SshService: SSH client for ${ws.username} closed.`);
if (activeSessions.has(ws)) { // Check if cleanup wasn't already called
if (!ws.CLOSED && !ws.CLOSING) {
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'SSH 连接已关闭。' }));
}
cleanupConnection(ws); // Ensure cleanup
}
};
const clientErrorHandler = (err: Error) => {
console.error(`SshService: SSH client error for ${ws.username}:`, err);
if (activeSessions.has(ws)) { // Check if cleanup wasn't already called
if (!ws.CLOSED && !ws.CLOSING) {
ws.send(JSON.stringify({ type: 'ssh:error', payload: `SSH 连接错误: ${err.message}` }));
}
cleanupConnection(ws); // Ensure cleanup
}
};
sshClient.on('close', clientCloseHandler);
sshClient.on('error', clientErrorHandler);
try {
ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在连接到 ${fullConnInfo.host}...` }));
await establishSshConnection(sshClient, connectConfig, fullConnInfo.proxy); // Use helper
ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' }));
// 5. Open Shell Stream
const shellStream = await new Promise<ClientChannel>((resolve, reject) => {
sshClient.shell((err, stream) => {
if (err) {
console.error(`SshService: User ${ws.username} failed to open shell:`, err);
return reject(new Error(`打开 Shell 失败: ${err.message}`));
}
console.log(`SshService: User ${ws.username} shell channel opened.`);
resolve(stream);
});
});
// 6. Store active session
const session: ActiveSshSession = { client: sshClient, shell: shellStream, connectionInfo: fullConnInfo };
activeSessions.set(ws, session);
console.log(`SshService: Active session stored for ${ws.username}.`);
// 7. Setup event forwarding for the shell stream
shellStream.on('data', (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' }));
}
});
shellStream.stderr.on('data', (data: Buffer) => {
console.error(`SSH Stderr (${ws.username}): ${data.toString('utf8').substring(0,100)}...`);
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ssh:output', payload: data.toString('base64'), encoding: 'base64' })); // Send stderr as output
}
});
shellStream.on('close', () => {
console.log(`SshService: Shell stream for ${ws.username} closed.`);
if (activeSessions.has(ws)) { // Check if cleanup wasn't already called by client close
if (!ws.CLOSED && !ws.CLOSING) {
ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' }));
}
cleanupConnection(ws); // Trigger cleanup if shell closes independently
}
});
// 8. Initialize SFTP (TODO: Move to SftpService) and Status Polling (TODO: Move to StatusMonitorService)
// For now, just notify connection success
ws.send(JSON.stringify({ type: 'ssh:connected' }));
// TODO: Call SftpService.initializeSftpSession(ws, sshClient);
// TODO: Call StatusMonitorService.startStatusPolling(ws, sshClient);
} catch (error: any) {
console.error(`SshService: Failed to connect or open shell for ${ws.username}:`, error);
// Ensure client listeners are removed and client is ended on failure
sshClient.removeListener('close', clientCloseHandler);
sshClient.removeListener('error', clientErrorHandler);
sshClient.end();
cleanupConnection(ws); // Clean up any partial state
throw error; // Re-throw for the controller
}
};
/**
* Sends input data to the SSH shell stream associated with a WebSocket connection.
* @param ws - The authenticated WebSocket client.
* @param data - The data string to send.
*/
export const sendInput = (ws: AuthenticatedWebSocket, data: string): void => {
const session = activeSessions.get(ws);
if (session?.shell && session.shell.writable) {
session.shell.write(data);
} else {
console.warn(`SshService: Cannot send input for ${ws.username}, no active/writable shell stream found.`);
// Optionally notify the client ws.send(...)
}
};
/**
* Resizes the pseudo-terminal associated with a WebSocket connection.
* @param ws - The authenticated WebSocket client.
* @param cols - Terminal width in columns.
* @param rows - Terminal height in rows.
*/
export const resizeTerminal = (ws: AuthenticatedWebSocket, cols: number, rows: number): void => {
const session = activeSessions.get(ws);
if (session?.shell) {
console.log(`SshService: Resizing terminal for ${ws.username} to ${cols}x${rows}`);
session.shell.setWindow(rows, cols, 0, 0); // Note: rows, cols order
} else {
console.warn(`SshService: Cannot resize terminal for ${ws.username}, no active shell stream found.`);
}
};
/**
* Cleans up SSH resources associated with a WebSocket connection.
* @param ws - The authenticated WebSocket client.
*/
export const cleanupConnection = (ws: AuthenticatedWebSocket): void => {
const session = activeSessions.get(ws);
if (session) {
console.log(`SshService: Cleaning up SSH session for ${ws.username}...`);
// TODO: Call StatusMonitorService.stopStatusPolling(ws);
// TODO: Call SftpService.cleanupSftpSession(ws);
// End streams and client
session.shell?.end(); // End the shell stream first
session.client?.end(); // End the main SSH client connection
activeSessions.delete(ws); // Remove from active sessions map
console.log(`SshService: SSH session for ${ws.username} cleaned up.`);
} else {
// console.log(`SshService: No active SSH session found for ${ws.username} during cleanup.`);
}
};
@@ -0,0 +1,79 @@
import * as TagRepository from '../repositories/tag.repository';
// Re-export or define types
export interface TagData extends TagRepository.TagData {}
/**
* 获取所有标签
*/
export const getAllTags = async (): Promise<TagData[]> => {
return TagRepository.findAllTags();
};
/**
* 根据 ID 获取单个标签
*/
export const getTagById = async (id: number): Promise<TagData | null> => {
return TagRepository.findTagById(id);
};
/**
* 创建新标签
*/
export const createTag = async (name: string): Promise<TagData> => {
// 1. Validate input
if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空。');
}
const trimmedName = name.trim();
// 2. Create tag record
try {
const newTagId = await TagRepository.createTag(trimmedName);
// 3. Fetch and return the newly created tag
const newTag = await getTagById(newTagId);
if (!newTag) {
throw new Error('创建标签后无法检索到该标签。');
}
return newTag;
} catch (error: any) {
if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`创建标签失败:标签名称 "${trimmedName}" 已存在。`);
}
throw error; // Re-throw other errors
}
};
/**
* 更新标签名称
*/
export const updateTag = async (id: number, name: string): Promise<TagData | null> => {
// 1. Validate input
if (!name || name.trim().length === 0) {
throw new Error('标签名称不能为空。');
}
const trimmedName = name.trim();
// 2. Update tag record
try {
const updated = await TagRepository.updateTag(id, trimmedName);
if (!updated) {
return null; // Tag not found or not updated
}
// 3. Fetch and return the updated tag
return getTagById(id);
} catch (error: any) {
if (error.message.includes('UNIQUE constraint failed')) {
throw new Error(`更新标签失败:标签名称 "${trimmedName}" 已存在。`);
}
throw error; // Re-throw other errors
}
};
/**
* 删除标签
*/
export const deleteTag = async (id: number): Promise<boolean> => {
// Repository handles cascading deletes in connection_tags
return TagRepository.deleteTag(id);
};