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
+1
View File
@@ -131,3 +131,4 @@ dist
/doc /doc
*.db *.db
/packages/data /packages/data
*.db
File diff suppressed because it is too large Load Diff
@@ -1,232 +1,132 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { getDb } from '../database'; import * as ProxyService from '../services/proxy.service';
import { encrypt, decrypt } from '../utils/crypto'; // 引入加解密工具
// 定义代理信息接口 (用于类型提示) // Helper function to remove sensitive fields for response
interface ProxyData { const sanitizeProxy = (proxy: ProxyService.ProxyData | null): Partial<ProxyService.ProxyData> | null => {
name: string; if (!proxy) return null;
type: 'SOCKS5' | 'HTTP'; const { encrypted_password, encrypted_private_key, encrypted_passphrase, ...sanitized } = proxy;
host: string; return sanitized;
port: number; };
username?: string | null;
password?: string | null; // 接收原始密码
}
// 获取所有代理配置 (不含密码) // 获取所有代理配置 (不含敏感信息)
export const getAllProxies = async (req: Request, res: Response) => { export const getAllProxies = async (req: Request, res: Response) => {
const db = getDb();
try { try {
// 查询所有代理,排除 encrypted_password 字段 const proxies = await ProxyService.getAllProxies();
const sql = `SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies`; // Sanitize each proxy before sending
const proxies = await new Promise<any[]>((resolve, reject) => { res.status(200).json(proxies.map(sanitizeProxy));
db.all(sql, [], (err, rows) => {
if (err) {
return reject(err);
}
resolve(rows);
});
});
res.status(200).json(proxies);
} catch (error: any) { } catch (error: any) {
console.error('Controller: 获取代理列表失败:', error);
res.status(500).json({ message: '获取代理列表失败', error: error.message }); res.status(500).json({ message: '获取代理列表失败', error: error.message });
} }
}; };
// 获取单个代理配置 (不含密码) // 获取单个代理配置 (不含敏感信息)
export const getProxyById = async (req: Request, res: Response) => { export const getProxyById = async (req: Request, res: Response) => {
const db = getDb();
const { id } = req.params; const { id } = req.params;
try { try {
// 查询单个代理,排除 encrypted_password 字段 const proxyId = parseInt(id, 10);
const sql = `SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies WHERE id = ?`; if (isNaN(proxyId)) {
const proxy = await new Promise<any>((resolve, reject) => { return res.status(400).json({ message: '无效的代理 ID' });
db.get(sql, [id], (err, row) => { }
if (err) { const proxy = await ProxyService.getProxyById(proxyId);
return reject(err);
}
resolve(row); // 如果找不到,row 会是 undefined
});
});
if (proxy) { if (proxy) {
res.status(200).json(proxy); res.status(200).json(sanitizeProxy(proxy));
} else { } else {
res.status(404).json({ message: `未找到 ID 为 ${id} 的代理` }); res.status(404).json({ message: `未找到 ID 为 ${id} 的代理` });
} }
} catch (error: any) { } catch (error: any) {
console.error(`Controller: 获取代理 ${id} 失败:`, error);
res.status(500).json({ message: `获取代理 ${id} 失败`, error: error.message }); res.status(500).json({ message: `获取代理 ${id} 失败`, error: error.message });
} }
}; };
// 创建新的代理配置 // 创建新的代理配置
export const createProxy = async (req: Request, res: Response) => { export const createProxy = async (req: Request, res: Response) => {
const db = getDb();
const { name, type, host, port, username, password }: ProxyData = req.body;
const now = Math.floor(Date.now() / 1000); // 当前时间戳 (秒)
// 基本验证
if (!name || !type || !host || !port) {
return res.status(400).json({ message: '缺少必要的代理信息 (name, type, host, port)' });
}
if (type !== 'SOCKS5' && type !== 'HTTP') {
return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' });
}
try { try {
let encryptedPassword: string | null = null; // Basic validation (more in service)
if (password) { const { name, type, host, port } = req.body;
encryptedPassword = encrypt(password); // 加密密码 if (!name || !type || !host || !port) {
return res.status(400).json({ message: '缺少必要的代理信息 (name, type, host, port)' });
}
if (type !== 'SOCKS5' && type !== 'HTTP') {
return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' });
} }
const sql = `INSERT INTO proxies (name, type, host, port, username, encrypted_password, created_at, updated_at) const newProxy = await ProxyService.createProxy(req.body);
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; res.status(201).json({
const params = [name, type, host, port, username ?? null, encryptedPassword, now, now]; message: '代理创建成功',
proxy: sanitizeProxy(newProxy) // Return sanitized proxy
// 使用 Promise 包装 db.run 以便使用 async/await
const result = await new Promise<{ id: number } | null>((resolve, reject) => {
db.run(sql, params, function (err) { // 使用 function 获取 this.lastID
if (err) {
return reject(err);
}
// this.lastID 包含新插入行的 ID
resolve({ id: this.lastID });
});
}); });
if (result) {
// 返回成功消息和新创建的代理信息 (不含密码)
res.status(201).json({
message: '代理创建成功',
proxy: {
id: result.id,
name,
type,
host,
port,
username: username ?? null,
created_at: now,
updated_at: now
}
});
} else {
// 这理论上不应该发生,除非 db.run 内部逻辑问题
throw new Error('未能获取新创建代理的 ID');
}
} catch (error: any) { } catch (error: any) {
if (error.message.includes('UNIQUE constraint failed')) { console.error('Controller: 创建代理失败:', error);
// 可以添加更具体的唯一约束错误处理,例如判断是哪个字段冲突 if (error.message.includes('UNIQUE constraint failed') || error.message.includes('同名字段冲突')) {
return res.status(409).json({ message: '创建代理失败:可能存在同名字段冲突', error: error.message }); return res.status(409).json({ message: '创建代理失败:可能存在同名字段冲突', error: error.message });
} }
if (error.message.includes('缺少') || error.message.includes('需要提供')) {
return res.status(400).json({ message: error.message });
}
res.status(500).json({ message: '创建代理失败', error: error.message }); res.status(500).json({ message: '创建代理失败', error: error.message });
} }
}; };
// 更新代理配置 // 更新代理配置
export const updateProxy = async (req: Request, res: Response) => { export const updateProxy = async (req: Request, res: Response) => {
const db = getDb();
const { id } = req.params; const { id } = req.params;
const { name, type, host, port, username, password }: Partial<ProxyData> = req.body;
const now = Math.floor(Date.now() / 1000);
// 验证至少有一个字段被更新
if (!name && !type && !host && port === undefined && username === undefined && password === undefined) {
return res.status(400).json({ message: '没有提供任何要更新的字段' });
}
if (type && type !== 'SOCKS5' && type !== 'HTTP') {
return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' });
}
try { try {
let encryptedPasswordToUpdate: string | null | undefined = undefined; // undefined 表示不更新密码 const proxyId = parseInt(id, 10);
if (password !== undefined) { // 检查 password 字段是否存在于请求体中 if (isNaN(proxyId)) {
encryptedPasswordToUpdate = password ? encrypt(password) : null; // 如果提供了新密码则加密,如果提供空字符串或 null 则设为 null return res.status(400).json({ message: '无效的代理 ID' });
} }
// 构建动态 SQL 更新语句 // Basic validation (more in service)
const fieldsToUpdate: string[] = []; const { name, type, host, port, username, password, auth_method, private_key, passphrase } = req.body;
const params: any[] = []; if (!name && !type && !host && port === undefined && username === undefined && password === undefined && auth_method === undefined && private_key === undefined && passphrase === undefined) {
return res.status(400).json({ message: '没有提供任何要更新的字段' });
if (name !== undefined) { fieldsToUpdate.push('name = ?'); params.push(name); } }
if (type !== undefined) { fieldsToUpdate.push('type = ?'); params.push(type); } if (type && type !== 'SOCKS5' && type !== 'HTTP') {
if (host !== undefined) { fieldsToUpdate.push('host = ?'); params.push(host); } return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' });
if (port !== undefined) { fieldsToUpdate.push('port = ?'); params.push(port); }
// username 可以设为 null
if (username !== undefined) { fieldsToUpdate.push('username = ?'); params.push(username ?? null); }
// 只有当 password 在请求体中明确提供了 (包括空字符串或 null),才更新密码字段
if (encryptedPasswordToUpdate !== undefined) {
fieldsToUpdate.push('encrypted_password = ?');
params.push(encryptedPasswordToUpdate);
} }
// 总是更新 updated_at 时间戳 const updatedProxy = await ProxyService.updateProxy(proxyId, req.body);
fieldsToUpdate.push('updated_at = ?');
params.push(now);
// 添加 WHERE 条件的参数 if (updatedProxy) {
params.push(id); res.status(200).json({ message: '代理更新成功', proxy: sanitizeProxy(updatedProxy) });
const sql = `UPDATE proxies SET ${fieldsToUpdate.join(', ')} WHERE id = ?`;
const result = await new Promise<{ changes: number }>((resolve, reject) => {
db.run(sql, params, function (err) { // 使用 function 获取 this.changes
if (err) {
return reject(err);
}
// this.changes 包含受影响的行数
resolve({ changes: this.changes });
});
});
if (result.changes > 0) {
// 更新成功后,获取更新后的代理信息 (不含密码) 并返回
const updatedProxy = await new Promise<any>((resolve, reject) => {
db.get(`SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies WHERE id = ?`, [id], (err, row) => {
if (err) return reject(err);
resolve(row);
});
});
if (updatedProxy) {
res.status(200).json({ message: '代理更新成功', proxy: updatedProxy });
} else {
// 理论上更新成功后应该能找到,除非并发删除了
res.status(404).json({ message: `更新成功,但未能找到 ID 为 ${id} 的代理` });
}
} else { } else {
// 如果 changes 为 0,说明没有找到对应 ID 的代理
res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行更新` }); res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行更新` });
} }
} catch (error: any) { } catch (error: any) {
if (error.message.includes('UNIQUE constraint failed')) { console.error(`Controller: 更新代理 ${id} 失败:`, error);
if (error.message.includes('UNIQUE constraint failed') || error.message.includes('同名字段冲突')) {
return res.status(409).json({ message: '更新代理失败:可能存在同名字段冲突', error: error.message }); return res.status(409).json({ message: '更新代理失败:可能存在同名字段冲突', error: error.message });
} }
if (error.message.includes('需要提供')) {
return res.status(400).json({ message: error.message });
}
res.status(500).json({ message: `更新代理 ${id} 失败`, error: error.message }); res.status(500).json({ message: `更新代理 ${id} 失败`, error: error.message });
} }
}; };
// 删除代理配置 // 删除代理配置
export const deleteProxy = async (req: Request, res: Response) => { export const deleteProxy = async (req: Request, res: Response) => {
const db = getDb();
const { id } = req.params; const { id } = req.params;
try { try {
const sql = `DELETE FROM proxies WHERE id = ?`; const proxyId = parseInt(id, 10);
const result = await new Promise<{ changes: number }>((resolve, reject) => { if (isNaN(proxyId)) {
db.run(sql, [id], function (err) { // 使用 function 获取 this.changes return res.status(400).json({ message: '无效的代理 ID' });
if (err) { }
return reject(err);
}
resolve({ changes: this.changes });
});
});
if (result.changes > 0) { const deleted = await ProxyService.deleteProxy(proxyId);
if (deleted) {
res.status(200).json({ message: `代理 ${id} 删除成功` }); res.status(200).json({ message: `代理 ${id} 删除成功` });
} else { } else {
// 如果 changes 为 0,说明没有找到对应 ID 的代理
res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行删除` }); res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行删除` });
} }
} catch (error: any) { } catch (error: any) {
console.error(`Controller: 删除代理 ${id} 失败:`, error);
res.status(500).json({ message: `删除代理 ${id} 失败`, error: error.message }); res.status(500).json({ message: `删除代理 ${id} 失败`, error: error.message });
} }
}; };
@@ -0,0 +1,310 @@
import { Database, Statement } from 'sqlite3';
import { getDb } from '../database';
const db = getDb();
// 定义 Connection 类型 (可以从 controller 或 types 文件导入,暂时在此定义)
// 注意:这里不包含加密字段,因为 Repository 不应处理解密
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;
}
interface ConnectionWithTags extends ConnectionBase {
tag_ids: number[];
}
// 包含加密字段的完整类型,用于插入/更新
export interface FullConnectionData extends ConnectionBase { // <-- Added export
encrypted_password?: string | null;
encrypted_private_key?: string | null;
encrypted_passphrase?: string | null;
}
/**
* 获取所有连接及其标签
*/
export const findAllConnectionsWithTags = async (): Promise<ConnectionWithTags[]> => {
return new Promise((resolve, reject) => {
db.all(
`SELECT
c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id,
c.created_at, c.updated_at, c.last_connected_at,
GROUP_CONCAT(ct.tag_id) as tag_ids_str
FROM connections c
LEFT JOIN connection_tags ct ON c.id = ct.connection_id
GROUP BY c.id
ORDER BY c.name ASC`,
(err, rows: any[]) => {
if (err) {
console.error('Repository: 查询连接列表时出错:', err.message);
return reject(new Error('获取连接列表失败'));
}
const processedRows = rows.map(row => ({
...row,
tag_ids: row.tag_ids_str ? row.tag_ids_str.split(',').map(Number) : []
}));
resolve(processedRows);
}
);
});
};
/**
* 根据 ID 获取单个连接及其标签
*/
export const findConnectionByIdWithTags = async (id: number): Promise<ConnectionWithTags | null> => {
return new Promise((resolve, reject) => {
db.get(
`SELECT
c.id, c.name, c.host, c.port, c.username, c.auth_method, c.proxy_id,
c.created_at, c.updated_at, c.last_connected_at,
GROUP_CONCAT(ct.tag_id) as tag_ids_str
FROM connections c
LEFT JOIN connection_tags ct ON c.id = ct.connection_id
WHERE c.id = ?
GROUP BY c.id`,
[id],
(err, row: any) => {
if (err) {
console.error(`Repository: 查询连接 ${id} 时出错:`, err.message);
return reject(new Error('获取连接信息失败'));
}
if (row) {
row.tag_ids = row.tag_ids_str ? row.tag_ids_str.split(',').map(Number) : [];
delete row.tag_ids_str;
resolve(row);
} else {
resolve(null);
}
}
);
});
};
/**
* 根据 ID 获取单个连接的完整信息 (包括加密字段)
* 用于更新或测试连接等需要完整信息的场景
*/
export const findFullConnectionById = async (id: number): Promise<any | null> => {
return new Promise((resolve, reject) => {
// 查询连接信息,并 LEFT JOIN 代理信息 (因为测试连接需要)
// 注意:这里返回的结构比较复杂,服务层需要处理
db.get(
`SELECT
c.*, -- 选择 connections 表所有列
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, -- 包含代理的 auth_method
p.encrypted_password as proxy_encrypted_password,
p.encrypted_private_key as proxy_encrypted_private_key, -- 包含代理的 key
p.encrypted_passphrase as proxy_encrypted_passphrase -- 包含代理的 passphrase
FROM connections c
LEFT JOIN proxies p ON c.proxy_id = p.id
WHERE c.id = ?`,
[id],
(err, row: any) => {
if (err) {
console.error(`Repository: 查询连接 ${id} 详细信息时出错:`, err.message);
return reject(new Error('获取连接详细信息失败'));
}
resolve(row || null);
}
);
});
};
/**
* 创建新连接
*/
export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'>): Promise<number> => {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare(
`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
stmt.run(
data.name, data.host, data.port, data.username, data.auth_method,
data.encrypted_password ?? null, data.encrypted_private_key ?? null, data.encrypted_passphrase ?? null,
data.proxy_id ?? null,
now, now,
function (this: Statement, err: Error | null) {
stmt.finalize(); // 确保 finalize 被调用
if (err) {
console.error('Repository: 插入连接时出错:', err.message);
return reject(new Error('创建连接记录失败'));
}
resolve((this as any).lastID);
}
);
});
};
/**
* 更新连接信息
*/
export const updateConnection = async (id: number, data: Partial<Omit<FullConnectionData, 'id' | 'created_at' | 'last_connected_at'>>): Promise<boolean> => {
const fieldsToUpdate: { [key: string]: any } = { ...data };
const params: any[] = [];
// 移除 id, created_at, last_connected_at (不应通过此方法更新)
delete fieldsToUpdate.id;
delete fieldsToUpdate.created_at;
delete fieldsToUpdate.last_connected_at;
// 设置 updated_at
fieldsToUpdate.updated_at = Math.floor(Date.now() / 1000);
const setClauses = Object.keys(fieldsToUpdate).map(key => `${key} = ?`).join(', ');
Object.values(fieldsToUpdate).forEach(value => params.push(value ?? null)); // 处理 undefined 为 null
if (!setClauses) {
return false; // 没有要更新的字段
}
params.push(id); // 添加 WHERE id = ? 的参数
return new Promise((resolve, reject) => {
const stmt = db.prepare(
`UPDATE connections SET ${setClauses} WHERE id = ?`
);
stmt.run(...params, function (this: Statement, err: Error | null) {
stmt.finalize();
if (err) {
console.error(`Repository: 更新连接 ${id} 时出错:`, err.message);
return reject(new Error('更新连接记录失败'));
}
resolve((this as any).changes > 0);
});
});
};
/**
* 删除连接
*/
export const deleteConnection = async (id: number): Promise<boolean> => {
return new Promise((resolve, reject) => {
const stmt = db.prepare(
`DELETE FROM connections WHERE id = ?`
);
stmt.run(id, function (this: Statement, err: Error | null) {
stmt.finalize();
if (err) {
console.error(`Repository: 删除连接 ${id} 时出错:`, err.message);
return reject(new Error('删除连接记录失败'));
}
resolve((this as any).changes > 0);
});
});
};
/**
* 更新连接的标签关联
* @param connectionId 连接 ID
* @param tagIds 新的标签 ID 数组 (空数组表示清除所有标签)
*/
export const updateConnectionTags = async (connectionId: number, tagIds: number[]): Promise<void> => {
const deleteStmt = db.prepare(`DELETE FROM connection_tags WHERE connection_id = ?`);
const insertStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`);
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run('BEGIN TRANSACTION');
try {
// 1. 删除旧关联
deleteStmt.run(connectionId, (err: Error | null) => {
if (err) throw err;
});
deleteStmt.finalize();
// 2. 插入新关联 (如果 tagIds 不为空)
if (tagIds.length > 0) {
tagIds.forEach((tagId: any) => {
if (typeof tagId === 'number' && tagId > 0) {
insertStmt.run(connectionId, tagId, (err: Error | null) => {
if (err) throw err;
});
} else {
console.warn(`Repository: 更新连接 ${connectionId} 标签时,提供的 tag_id 无效: ${tagId}`);
}
});
}
insertStmt.finalize();
db.run('COMMIT', (commitErr: Error | null) => {
if (commitErr) throw commitErr;
resolve(); // 事务成功
});
} catch (tagError: any) {
console.error(`Repository: 更新连接 ${connectionId} 的标签关联时出错:`, tagError);
db.run('ROLLBACK');
reject(new Error('处理标签关联失败'));
}
});
});
};
/**
* 批量插入连接(用于导入)
* 注意:此函数应在事务中调用
*/
export const bulkInsertConnections = async (connections: Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'>[]): Promise<{ connectionId: number, originalData: any }[]> => {
const insertConnStmt = db.prepare(`INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const insertTagStmt = db.prepare(`INSERT INTO connection_tags (connection_id, tag_id) VALUES (?, ?)`);
const results: { connectionId: number, originalData: any }[] = [];
const now = Math.floor(Date.now() / 1000);
try {
for (const connData of connections) {
const connResult = await new Promise<{ lastID: number }>((resolve, reject) => {
insertConnStmt.run(
connData.name, connData.host, connData.port, connData.username, connData.auth_method,
connData.encrypted_password || null,
connData.encrypted_private_key || null,
connData.encrypted_passphrase || null,
connData.proxy_id || null,
now, now,
function (this: Statement, err: Error | null) {
if (err) return reject(new Error(`插入连接 "${connData.name}" 时出错: ${err.message}`));
resolve({ lastID: (this as any).lastID });
}
);
});
const newConnectionId = connResult.lastID;
results.push({ connectionId: newConnectionId, originalData: connData }); // Store ID and original data for tag association
// 处理标签关联 (在同一个事务中)
if (Array.isArray((connData as any).tag_ids) && (connData as any).tag_ids.length > 0) {
for (const tagId of (connData as any).tag_ids) {
if (typeof tagId === 'number' && tagId > 0) {
await new Promise<void>((resolve, reject) => {
insertTagStmt.run(newConnectionId, tagId, (err: Error | null) => {
if (err) {
// 警告但不中断整个导入
console.warn(`Repository: 导入连接 ${connData.name}: 关联标签 ID ${tagId} 失败: ${err.message}`);
}
resolve();
});
});
}
}
}
}
return results;
} finally {
// Finalize statements after the loop
insertConnStmt.finalize();
insertTagStmt.finalize();
}
};
@@ -0,0 +1,153 @@
import { Database, Statement } from 'sqlite3';
import { getDb } from '../database';
const db = getDb();
// 定义 Proxy 类型 (可以共享到 types 文件)
export interface ProxyData {
id: number;
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;
created_at: number;
updated_at: number;
}
/**
* 根据名称、类型、主机和端口查找代理
*/
export const findProxyByNameTypeHostPort = async (name: string, type: string, host: string, port: number): Promise<{ id: number } | undefined> => {
return new Promise((resolve, reject) => {
db.get(
`SELECT id FROM proxies WHERE name = ? AND type = ? AND host = ? AND port = ?`,
[name, type, host, port],
(err: Error | null, row: { id: number } | undefined) => {
if (err) {
console.error(`Repository: 查找代理时出错 (name=${name}, type=${type}, host=${host}, port=${port}):`, err.message);
return reject(new Error(`查找代理时出错: ${err.message}`));
}
resolve(row);
}
);
});
};
/**
* 创建新代理
*/
export const createProxy = async (data: Omit<ProxyData, 'id' | 'created_at' | 'updated_at'>): Promise<number> => {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare(
`INSERT INTO proxies (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
stmt.run(
data.name, data.type, data.host, data.port,
data.username || null,
data.auth_method || 'none',
data.encrypted_password || null,
data.encrypted_private_key || null,
data.encrypted_passphrase || null,
now, now,
function (this: Statement, err: Error | null) {
stmt.finalize();
if (err) {
console.error('Repository: 创建代理时出错:', err.message);
return reject(new Error(`创建代理时出错: ${err.message}`));
}
resolve((this as any).lastID);
}
);
});
};
/**
* 获取所有代理
*/
export const findAllProxies = async (): Promise<ProxyData[]> => {
return new Promise((resolve, reject) => {
db.all(`SELECT * FROM proxies ORDER BY name ASC`, (err, rows: ProxyData[]) => {
if (err) {
console.error('Repository: 查询代理列表时出错:', err.message);
return reject(new Error('获取代理列表失败'));
}
resolve(rows);
});
});
};
/**
* 根据 ID 获取单个代理
*/
export const findProxyById = async (id: number): Promise<ProxyData | null> => {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM proxies WHERE id = ?`, [id], (err, row: ProxyData) => {
if (err) {
console.error(`Repository: 查询代理 ${id} 时出错:`, err.message);
return reject(new Error('获取代理信息失败'));
}
resolve(row || null);
});
});
};
/**
* 更新代理信息
*/
export const updateProxy = async (id: number, data: Partial<Omit<ProxyData, 'id' | 'created_at'>>): Promise<boolean> => {
const fieldsToUpdate: { [key: string]: any } = { ...data };
const params: any[] = [];
delete fieldsToUpdate.id;
delete fieldsToUpdate.created_at;
fieldsToUpdate.updated_at = Math.floor(Date.now() / 1000);
const setClauses = Object.keys(fieldsToUpdate).map(key => `${key} = ?`).join(', ');
Object.values(fieldsToUpdate).forEach(value => params.push(value ?? null));
if (!setClauses) {
return false;
}
params.push(id);
return new Promise((resolve, reject) => {
const stmt = db.prepare(`UPDATE proxies SET ${setClauses} WHERE id = ?`);
stmt.run(...params, function (this: Statement, err: Error | null) {
stmt.finalize();
if (err) {
console.error(`Repository: 更新代理 ${id} 时出错:`, err.message);
return reject(new Error('更新代理记录失败'));
}
resolve((this as any).changes > 0);
});
});
};
/**
* 删除代理
*/
export const deleteProxy = async (id: number): Promise<boolean> => {
return new Promise((resolve, reject) => {
// 注意:connections 表中的 proxy_id 外键设置了 ON DELETE SET NULL
// 所以删除代理时,关联的连接会自动将 proxy_id 设为 NULL。
const stmt = db.prepare(`DELETE FROM proxies WHERE id = ?`);
stmt.run(id, function (this: Statement, err: Error | null) {
stmt.finalize();
if (err) {
console.error(`Repository: 删除代理 ${id} 时出错:`, err.message);
return reject(new Error('删除代理记录失败'));
}
resolve((this as any).changes > 0);
});
});
};
@@ -0,0 +1,104 @@
import { Database, Statement } from 'sqlite3';
import { getDb } from '../database';
const db = getDb();
// 定义 Tag 类型 (可以共享到 types 文件)
export interface TagData {
id: number;
name: string;
created_at: number;
updated_at: number; // Assuming tags also have updated_at based on migrations
}
/**
* 获取所有标签
*/
export const findAllTags = async (): Promise<TagData[]> => {
return new Promise((resolve, reject) => {
db.all(`SELECT * FROM tags ORDER BY name ASC`, [], (err, rows: TagData[]) => {
if (err) {
console.error('Repository: 查询标签列表时出错:', err.message);
return reject(new Error('获取标签列表失败'));
}
resolve(rows);
});
});
};
/**
* 根据 ID 获取单个标签
*/
export const findTagById = async (id: number): Promise<TagData | null> => {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM tags WHERE id = ?`, [id], (err, row: TagData) => {
if (err) {
console.error(`Repository: 查询标签 ${id} 时出错:`, err.message);
return reject(new Error('获取标签信息失败'));
}
resolve(row || null);
});
});
};
/**
* 创建新标签
*/
export const createTag = async (name: string): Promise<number> => {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare(
`INSERT INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)`
);
stmt.run(name, now, now, function (this: Statement, err: Error | null) {
stmt.finalize();
if (err) {
// Handle unique constraint error specifically if needed
console.error('Repository: 创建标签时出错:', err.message);
return reject(new Error(`创建标签失败: ${err.message}`));
}
resolve((this as any).lastID);
});
});
};
/**
* 更新标签名称
*/
export const updateTag = async (id: number, name: string): Promise<boolean> => {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare(
`UPDATE tags SET name = ?, updated_at = ? WHERE id = ?`
);
stmt.run(name, now, id, function (this: Statement, err: Error | null) {
stmt.finalize();
if (err) {
// Handle unique constraint error specifically if needed
console.error(`Repository: 更新标签 ${id} 时出错:`, err.message);
return reject(new Error(`更新标签失败: ${err.message}`));
}
resolve((this as any).changes > 0);
});
});
};
/**
* 删除标签
*/
export const deleteTag = async (id: number): Promise<boolean> => {
return new Promise((resolve, reject) => {
// Note: connection_tags junction table has ON DELETE CASCADE for tag_id,
// so related entries there will be deleted automatically.
const stmt = db.prepare(`DELETE FROM tags WHERE id = ?`);
stmt.run(id, function (this: Statement, err: Error | null) {
stmt.finalize();
if (err) {
console.error(`Repository: 删除标签 ${id} 时出错:`, err.message);
return reject(new Error('删除标签失败'));
}
resolve((this as any).changes > 0);
});
});
};
@@ -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);
};
+28 -126
View File
@@ -1,59 +1,27 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Statement } from 'sqlite3'; import * as TagService from '../services/tag.service';
import { getDb } from '../database';
const db = getDb();
// 标签数据结构 (用于类型提示)
interface TagInfo {
id: number;
name: string;
created_at: number;
updated_at: number;
}
/** /**
* 创建新标签 (POST /api/v1/tags) * 创建新标签 (POST /api/v1/tags)
*/ */
export const createTag = async (req: Request, res: Response): Promise<void> => { export const createTag = async (req: Request, res: Response): Promise<void> => {
const { name } = req.body; const { name } = req.body;
const userId = req.session.userId; // 保留以备将来多用户支持
if (!name || typeof name !== 'string' || name.trim() === '') { if (!name || typeof name !== 'string' || name.trim() === '') {
res.status(400).json({ message: '标签名称不能为空。' }); res.status(400).json({ message: '标签名称不能为空。' });
return; return;
} }
const tagName = name.trim();
const now = Math.floor(Date.now() / 1000);
try { try {
// 插入数据库,name 字段有 UNIQUE 约束,重复会报错 const newTag = await TagService.createTag(name);
const result = await new Promise<{ lastID: number }>((resolve, reject) => { res.status(201).json({ message: '标签创建成功。', tag: newTag });
const stmt = db.prepare(
`INSERT INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)`
);
stmt.run(tagName, now, now, function (this: Statement, err: Error | null) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
return reject(new Error(`标签 "${tagName}" 已存在。`));
}
console.error('插入标签时出错:', err.message);
return reject(new Error('创建标签失败'));
}
resolve({ lastID: (this as any).lastID });
});
stmt.finalize();
});
res.status(201).json({
message: '标签创建成功。',
tag: { id: result.lastID, name: tagName, created_at: now, updated_at: now }
});
} catch (error: any) { } catch (error: any) {
console.error('创建标签时发生错误:', error); console.error('Controller: 创建标签时发生错误:', error);
res.status(500).json({ message: error.message || '创建标签时发生内部服务器错误。' }); if (error.message.includes('已存在')) {
res.status(409).json({ message: error.message }); // Conflict
} else {
res.status(500).json({ message: error.message || '创建标签时发生内部服务器错误。' });
}
} }
}; };
@@ -61,24 +29,11 @@ export const createTag = async (req: Request, res: Response): Promise<void> => {
* 获取标签列表 (GET /api/v1/tags) * 获取标签列表 (GET /api/v1/tags)
*/ */
export const getTags = async (req: Request, res: Response): Promise<void> => { export const getTags = async (req: Request, res: Response): Promise<void> => {
const userId = req.session.userId; // 保留
try { try {
const tags = await new Promise<TagInfo[]>((resolve, reject) => { const tags = await TagService.getAllTags();
db.all(
`SELECT id, name, created_at, updated_at FROM tags ORDER BY name ASC`,
(err, rows: TagInfo[]) => {
if (err) {
console.error('查询标签列表时出错:', err.message);
return reject(new Error('获取标签列表失败'));
}
resolve(rows);
}
);
});
res.status(200).json(tags); res.status(200).json(tags);
} catch (error: any) { } catch (error: any) {
console.error('获取标签列表时发生错误:', error); console.error('Controller: 获取标签列表时发生错误:', error);
res.status(500).json({ message: error.message || '获取标签列表时发生内部服务器错误。' }); res.status(500).json({ message: error.message || '获取标签列表时发生内部服务器错误。' });
} }
}; };
@@ -88,7 +43,6 @@ export const getTags = async (req: Request, res: Response): Promise<void> => {
*/ */
export const getTagById = async (req: Request, res: Response): Promise<void> => { export const getTagById = async (req: Request, res: Response): Promise<void> => {
const tagId = parseInt(req.params.id, 10); const tagId = parseInt(req.params.id, 10);
const userId = req.session.userId; // 保留
if (isNaN(tagId)) { if (isNaN(tagId)) {
res.status(400).json({ message: '无效的标签 ID。' }); res.status(400).json({ message: '无效的标签 ID。' });
@@ -96,27 +50,14 @@ export const getTagById = async (req: Request, res: Response): Promise<void> =>
} }
try { try {
const tag = await new Promise<TagInfo | null>((resolve, reject) => { const tag = await TagService.getTagById(tagId);
db.get(
`SELECT id, name, created_at, updated_at FROM tags WHERE id = ?`,
[tagId],
(err, row: TagInfo) => {
if (err) {
console.error(`查询标签 ${tagId} 时出错:`, err.message);
return reject(new Error('获取标签信息失败'));
}
resolve(row || null);
}
);
});
if (!tag) { if (!tag) {
res.status(404).json({ message: '标签未找到。' }); res.status(404).json({ message: '标签未找到。' });
} else { } else {
res.status(200).json(tag); res.status(200).json(tag);
} }
} catch (error: any) { } catch (error: any) {
console.error(`获取标签 ${tagId} 时发生错误:`, error); console.error(`Controller: 获取标签 ${tagId} 时发生错误:`, error);
res.status(500).json({ message: error.message || '获取标签信息时发生内部服务器错误。' }); res.status(500).json({ message: error.message || '获取标签信息时发生内部服务器错误。' });
} }
}; };
@@ -127,7 +68,6 @@ export const getTagById = async (req: Request, res: Response): Promise<void> =>
export const updateTag = async (req: Request, res: Response): Promise<void> => { export const updateTag = async (req: Request, res: Response): Promise<void> => {
const tagId = parseInt(req.params.id, 10); const tagId = parseInt(req.params.id, 10);
const { name } = req.body; const { name } = req.body;
const userId = req.session.userId; // 保留
if (isNaN(tagId)) { if (isNaN(tagId)) {
res.status(400).json({ message: '无效的标签 ID。' }); res.status(400).json({ message: '无效的标签 ID。' });
@@ -138,43 +78,23 @@ export const updateTag = async (req: Request, res: Response): Promise<void> => {
return; return;
} }
const tagName = name.trim();
const now = Math.floor(Date.now() / 1000);
try { try {
const result = await new Promise<{ changes: number }>((resolve, reject) => { const updatedTag = await TagService.updateTag(tagId, name);
const stmt = db.prepare( if (!updatedTag) {
`UPDATE tags SET name = ?, updated_at = ? WHERE id = ?` res.status(404).json({ message: '标签未找到。' });
);
stmt.run(tagName, now, tagId, function (this: Statement, err: Error | null) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
return reject(new Error(`标签名称 "${tagName}" 已存在。`));
}
console.error(`更新标签 ${tagId} 时出错:`, err.message);
return reject(new Error('更新标签失败'));
}
resolve({ changes: (this as any).changes });
});
stmt.finalize();
});
if (result.changes === 0) {
res.status(404).json({ message: '标签未找到或名称未更改。' });
} else { } else {
// 获取更新后的信息并返回
const updatedTag = await new Promise<TagInfo | null>((resolve, reject) => {
db.get(
`SELECT id, name, created_at, updated_at FROM tags WHERE id = ?`,
[tagId],
(err, row: TagInfo) => err ? reject(err) : resolve(row || null)
);
});
res.status(200).json({ message: '标签更新成功。', tag: updatedTag }); res.status(200).json({ message: '标签更新成功。', tag: updatedTag });
} }
} catch (error: any) { } catch (error: any) {
console.error(`更新标签 ${tagId} 时发生错误:`, error); console.error(`Controller: 更新标签 ${tagId} 时发生错误:`, error);
res.status(500).json({ message: error.message || '更新标签时发生内部服务器错误。' }); if (error.message.includes('已存在')) {
res.status(409).json({ message: error.message }); // Conflict
} else if (error.message.includes('不能为空')) {
res.status(400).json({ message: error.message });
}
else {
res.status(500).json({ message: error.message || '更新标签时发生内部服务器错误。' });
}
} }
}; };
@@ -183,7 +103,6 @@ export const updateTag = async (req: Request, res: Response): Promise<void> => {
*/ */
export const deleteTag = async (req: Request, res: Response): Promise<void> => { export const deleteTag = async (req: Request, res: Response): Promise<void> => {
const tagId = parseInt(req.params.id, 10); const tagId = parseInt(req.params.id, 10);
const userId = req.session.userId; // 保留
if (isNaN(tagId)) { if (isNaN(tagId)) {
res.status(400).json({ message: '无效的标签 ID。' }); res.status(400).json({ message: '无效的标签 ID。' });
@@ -191,31 +110,14 @@ export const deleteTag = async (req: Request, res: Response): Promise<void> => {
} }
try { try {
// TODO: 在删除标签前,需要考虑处理 connection_tags 关联表中的数据 const deleted = await TagService.deleteTag(tagId);
// 例如:可以选择删除关联记录,或者阻止删除有关联的标签 if (!deleted) {
// 当前简化处理:直接删除标签
const result = await new Promise<{ changes: number }>((resolve, reject) => {
const stmt = db.prepare(
`DELETE FROM tags WHERE id = ?`
);
stmt.run(tagId, function (this: Statement, err: Error | null) {
if (err) {
console.error(`删除标签 ${tagId} 时出错:`, err.message);
return reject(new Error('删除标签失败'));
}
resolve({ changes: (this as any).changes });
});
stmt.finalize();
});
if (result.changes === 0) {
res.status(404).json({ message: '标签未找到。' }); res.status(404).json({ message: '标签未找到。' });
} else { } else {
res.status(200).json({ message: '标签删除成功。' }); res.status(200).json({ message: '标签删除成功。' });
} }
} catch (error: any) { } catch (error: any) {
console.error(`删除标签 ${tagId} 时发生错误:`, error); console.error(`Controller: 删除标签 ${tagId} 时发生错误:`, error);
res.status(500).json({ message: error.message || '删除标签时发生内部服务器错误。' }); res.status(500).json({ message: error.message || '删除标签时发生内部服务器错误。' });
} }
}; };