update
This commit is contained in:
@@ -131,3 +131,4 @@ dist
|
||||
/doc
|
||||
*.db
|
||||
/packages/data
|
||||
*.db
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,232 +1,132 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getDb } from '../database';
|
||||
import { encrypt, decrypt } from '../utils/crypto'; // 引入加解密工具
|
||||
import * as ProxyService from '../services/proxy.service';
|
||||
|
||||
// 定义代理信息接口 (用于类型提示)
|
||||
interface ProxyData {
|
||||
name: string;
|
||||
type: 'SOCKS5' | 'HTTP';
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string | null;
|
||||
password?: string | null; // 接收原始密码
|
||||
}
|
||||
// Helper function to remove sensitive fields for response
|
||||
const sanitizeProxy = (proxy: ProxyService.ProxyData | null): Partial<ProxyService.ProxyData> | null => {
|
||||
if (!proxy) return null;
|
||||
const { encrypted_password, encrypted_private_key, encrypted_passphrase, ...sanitized } = proxy;
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
// 获取所有代理配置 (不含密码)
|
||||
// 获取所有代理配置 (不含敏感信息)
|
||||
export const getAllProxies = async (req: Request, res: Response) => {
|
||||
const db = getDb();
|
||||
try {
|
||||
// 查询所有代理,排除 encrypted_password 字段
|
||||
const sql = `SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies`;
|
||||
const proxies = await new Promise<any[]>((resolve, reject) => {
|
||||
db.all(sql, [], (err, rows) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(rows);
|
||||
});
|
||||
});
|
||||
res.status(200).json(proxies);
|
||||
const proxies = await ProxyService.getAllProxies();
|
||||
// Sanitize each proxy before sending
|
||||
res.status(200).json(proxies.map(sanitizeProxy));
|
||||
} catch (error: any) {
|
||||
console.error('Controller: 获取代理列表失败:', error);
|
||||
res.status(500).json({ message: '获取代理列表失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取单个代理配置 (不含密码)
|
||||
// 获取单个代理配置 (不含敏感信息)
|
||||
export const getProxyById = async (req: Request, res: Response) => {
|
||||
const db = getDb();
|
||||
const { id } = req.params;
|
||||
try {
|
||||
// 查询单个代理,排除 encrypted_password 字段
|
||||
const sql = `SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies WHERE id = ?`;
|
||||
const proxy = await new Promise<any>((resolve, reject) => {
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(row); // 如果找不到,row 会是 undefined
|
||||
});
|
||||
});
|
||||
const proxyId = parseInt(id, 10);
|
||||
if (isNaN(proxyId)) {
|
||||
return res.status(400).json({ message: '无效的代理 ID' });
|
||||
}
|
||||
const proxy = await ProxyService.getProxyById(proxyId);
|
||||
|
||||
if (proxy) {
|
||||
res.status(200).json(proxy);
|
||||
res.status(200).json(sanitizeProxy(proxy));
|
||||
} else {
|
||||
res.status(404).json({ message: `未找到 ID 为 ${id} 的代理` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 获取代理 ${id} 失败:`, error);
|
||||
res.status(500).json({ message: `获取代理 ${id} 失败`, error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新的代理配置
|
||||
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 {
|
||||
let encryptedPassword: string | null = null;
|
||||
if (password) {
|
||||
encryptedPassword = encrypt(password); // 加密密码
|
||||
// Basic validation (more in service)
|
||||
const { name, type, host, port } = req.body;
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
const params = [name, type, host, port, username ?? null, encryptedPassword, now, now];
|
||||
|
||||
// 使用 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 });
|
||||
});
|
||||
const newProxy = await ProxyService.createProxy(req.body);
|
||||
res.status(201).json({
|
||||
message: '代理创建成功',
|
||||
proxy: sanitizeProxy(newProxy) // Return sanitized proxy
|
||||
});
|
||||
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
if (error.message.includes('缺少') || error.message.includes('需要提供')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
res.status(500).json({ message: '创建代理失败', error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 更新代理配置
|
||||
export const updateProxy = async (req: Request, res: Response) => {
|
||||
const db = getDb();
|
||||
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 {
|
||||
let encryptedPasswordToUpdate: string | null | undefined = undefined; // undefined 表示不更新密码
|
||||
if (password !== undefined) { // 检查 password 字段是否存在于请求体中
|
||||
encryptedPasswordToUpdate = password ? encrypt(password) : null; // 如果提供了新密码则加密,如果提供空字符串或 null 则设为 null
|
||||
const proxyId = parseInt(id, 10);
|
||||
if (isNaN(proxyId)) {
|
||||
return res.status(400).json({ message: '无效的代理 ID' });
|
||||
}
|
||||
|
||||
// 构建动态 SQL 更新语句
|
||||
const fieldsToUpdate: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (name !== undefined) { fieldsToUpdate.push('name = ?'); params.push(name); }
|
||||
if (type !== undefined) { fieldsToUpdate.push('type = ?'); params.push(type); }
|
||||
if (host !== undefined) { fieldsToUpdate.push('host = ?'); params.push(host); }
|
||||
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);
|
||||
// Basic validation (more in service)
|
||||
const { name, type, host, port, username, password, auth_method, private_key, passphrase } = req.body;
|
||||
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 (type && type !== 'SOCKS5' && type !== 'HTTP') {
|
||||
return res.status(400).json({ message: '无效的代理类型,仅支持 SOCKS5 或 HTTP' });
|
||||
}
|
||||
|
||||
// 总是更新 updated_at 时间戳
|
||||
fieldsToUpdate.push('updated_at = ?');
|
||||
params.push(now);
|
||||
const updatedProxy = await ProxyService.updateProxy(proxyId, req.body);
|
||||
|
||||
// 添加 WHERE 条件的参数
|
||||
params.push(id);
|
||||
|
||||
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} 的代理` });
|
||||
}
|
||||
if (updatedProxy) {
|
||||
res.status(200).json({ message: '代理更新成功', proxy: sanitizeProxy(updatedProxy) });
|
||||
} else {
|
||||
// 如果 changes 为 0,说明没有找到对应 ID 的代理
|
||||
res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行更新` });
|
||||
}
|
||||
|
||||
} 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 });
|
||||
}
|
||||
if (error.message.includes('需要提供')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
res.status(500).json({ message: `更新代理 ${id} 失败`, error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 删除代理配置
|
||||
export const deleteProxy = async (req: Request, res: Response) => {
|
||||
const db = getDb();
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const sql = `DELETE FROM proxies WHERE id = ?`;
|
||||
const result = await new Promise<{ changes: number }>((resolve, reject) => {
|
||||
db.run(sql, [id], function (err) { // 使用 function 获取 this.changes
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ changes: this.changes });
|
||||
});
|
||||
});
|
||||
const proxyId = parseInt(id, 10);
|
||||
if (isNaN(proxyId)) {
|
||||
return res.status(400).json({ message: '无效的代理 ID' });
|
||||
}
|
||||
|
||||
if (result.changes > 0) {
|
||||
const deleted = await ProxyService.deleteProxy(proxyId);
|
||||
|
||||
if (deleted) {
|
||||
res.status(200).json({ message: `代理 ${id} 删除成功` });
|
||||
} else {
|
||||
// 如果 changes 为 0,说明没有找到对应 ID 的代理
|
||||
res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行删除` });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 删除代理 ${id} 失败:`, error);
|
||||
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);
|
||||
};
|
||||
@@ -1,59 +1,27 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Statement } from 'sqlite3';
|
||||
import { getDb } from '../database';
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// 标签数据结构 (用于类型提示)
|
||||
interface TagInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
import * as TagService from '../services/tag.service';
|
||||
|
||||
/**
|
||||
* 创建新标签 (POST /api/v1/tags)
|
||||
*/
|
||||
export const createTag = async (req: Request, res: Response): Promise<void> => {
|
||||
const { name } = req.body;
|
||||
const userId = req.session.userId; // 保留以备将来多用户支持
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim() === '') {
|
||||
res.status(400).json({ message: '标签名称不能为空。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = name.trim();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
// 插入数据库,name 字段有 UNIQUE 约束,重复会报错
|
||||
const result = await new Promise<{ lastID: number }>((resolve, reject) => {
|
||||
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 }
|
||||
});
|
||||
|
||||
const newTag = await TagService.createTag(name);
|
||||
res.status(201).json({ message: '标签创建成功。', tag: newTag });
|
||||
} catch (error: any) {
|
||||
console.error('创建标签时发生错误:', error);
|
||||
res.status(500).json({ message: error.message || '创建标签时发生内部服务器错误。' });
|
||||
console.error('Controller: 创建标签时发生错误:', error);
|
||||
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)
|
||||
*/
|
||||
export const getTags = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId; // 保留
|
||||
|
||||
try {
|
||||
const tags = await new Promise<TagInfo[]>((resolve, reject) => {
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
const tags = await TagService.getAllTags();
|
||||
res.status(200).json(tags);
|
||||
} catch (error: any) {
|
||||
console.error('获取标签列表时发生错误:', error);
|
||||
console.error('Controller: 获取标签列表时发生错误:', error);
|
||||
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> => {
|
||||
const tagId = parseInt(req.params.id, 10);
|
||||
const userId = req.session.userId; // 保留
|
||||
|
||||
if (isNaN(tagId)) {
|
||||
res.status(400).json({ message: '无效的标签 ID。' });
|
||||
@@ -96,27 +50,14 @@ export const getTagById = async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = await new Promise<TagInfo | null>((resolve, reject) => {
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const tag = await TagService.getTagById(tagId);
|
||||
if (!tag) {
|
||||
res.status(404).json({ message: '标签未找到。' });
|
||||
} else {
|
||||
res.status(200).json(tag);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`获取标签 ${tagId} 时发生错误:`, error);
|
||||
console.error(`Controller: 获取标签 ${tagId} 时发生错误:`, error);
|
||||
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> => {
|
||||
const tagId = parseInt(req.params.id, 10);
|
||||
const { name } = req.body;
|
||||
const userId = req.session.userId; // 保留
|
||||
|
||||
if (isNaN(tagId)) {
|
||||
res.status(400).json({ message: '无效的标签 ID。' });
|
||||
@@ -138,43 +78,23 @@ export const updateTag = async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = name.trim();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ changes: number }>((resolve, reject) => {
|
||||
const stmt = db.prepare(
|
||||
`UPDATE tags SET name = ?, updated_at = ? WHERE id = ?`
|
||||
);
|
||||
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: '标签未找到或名称未更改。' });
|
||||
const updatedTag = await TagService.updateTag(tagId, name);
|
||||
if (!updatedTag) {
|
||||
res.status(404).json({ message: '标签未找到。' });
|
||||
} 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 });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`更新标签 ${tagId} 时发生错误:`, error);
|
||||
res.status(500).json({ message: error.message || '更新标签时发生内部服务器错误。' });
|
||||
console.error(`Controller: 更新标签 ${tagId} 时发生错误:`, error);
|
||||
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> => {
|
||||
const tagId = parseInt(req.params.id, 10);
|
||||
const userId = req.session.userId; // 保留
|
||||
|
||||
if (isNaN(tagId)) {
|
||||
res.status(400).json({ message: '无效的标签 ID。' });
|
||||
@@ -191,31 +110,14 @@ export const deleteTag = async (req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: 在删除标签前,需要考虑处理 connection_tags 关联表中的数据
|
||||
// 例如:可以选择删除关联记录,或者阻止删除有关联的标签
|
||||
// 当前简化处理:直接删除标签
|
||||
|
||||
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) {
|
||||
const deleted = await TagService.deleteTag(tagId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ message: '标签未找到。' });
|
||||
} else {
|
||||
res.status(200).json({ message: '标签删除成功。' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`删除标签 ${tagId} 时发生错误:`, error);
|
||||
console.error(`Controller: 删除标签 ${tagId} 时发生错误:`, error);
|
||||
res.status(500).json({ message: error.message || '删除标签时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user