From 4f2f8b9f075946228a44e85cd4cc9a24b8993bea Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Tue, 15 Apr 2025 07:31:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E7=AB=AF:=20=E5=9C=A8?= =?UTF-8?q?=E5=BB=BA=E7=AB=8B=20SSH=20=E8=BF=9E=E6=8E=A5=E6=97=B6=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/package-lock.json | 57 +++- packages/backend/package.json | 2 + .../src/connections/connections.controller.ts | 131 +++++--- packages/backend/src/index.ts | 14 +- packages/backend/src/migrations.ts | 40 ++- .../backend/src/proxies/proxies.controller.ts | 232 +++++++++++++ .../backend/src/proxies/proxies.routes.ts | 24 ++ packages/backend/src/websocket.ts | 310 ++++++++++++------ packages/data/nexus-terminal.db | Bin 28672 -> 32768 bytes packages/frontend/src/App.vue | 1 + .../src/components/AddConnectionForm.vue | 119 +++++-- .../frontend/src/components/AddProxyForm.vue | 258 +++++++++++++++ .../frontend/src/components/ProxyList.vue | 110 +++++++ packages/frontend/src/locales/en.json | 54 ++- packages/frontend/src/locales/zh.json | 54 ++- packages/frontend/src/router/index.ts | 10 +- .../frontend/src/stores/connections.store.ts | 15 +- packages/frontend/src/stores/proxies.store.ts | 129 ++++++++ packages/frontend/src/views/ProxiesView.vue | 81 +++++ 19 files changed, 1444 insertions(+), 197 deletions(-) create mode 100644 packages/backend/src/proxies/proxies.controller.ts create mode 100644 packages/backend/src/proxies/proxies.routes.ts create mode 100644 packages/frontend/src/components/AddProxyForm.vue create mode 100644 packages/frontend/src/components/ProxyList.vue create mode 100644 packages/frontend/src/stores/proxies.store.ts create mode 100644 packages/frontend/src/views/ProxiesView.vue diff --git a/packages/backend/package-lock.json b/packages/backend/package-lock.json index b643f25..4531271 100644 --- a/packages/backend/package-lock.json +++ b/packages/backend/package-lock.json @@ -12,6 +12,8 @@ "connect-sqlite3": "^0.9.15", "express": "^5.1.0", "express-session": "^1.18.1", + "https-proxy-agent": "^7.0.6", + "socks": "^2.8.4", "sqlite3": "^5.1.7", "ssh2": "^1.16.0", "ws": "^8.18.1" @@ -97,6 +99,19 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -1451,16 +1466,25 @@ } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" } }, "node_modules/humanize-ms": { @@ -1560,7 +1584,6 @@ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "license": "MIT", - "optional": true, "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" @@ -1673,8 +1696,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/lru-cache": { "version": "6.0.0", @@ -1748,6 +1770,20 @@ "node": ">= 10" } }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/make-fetch-happen/node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -2689,7 +2725,6 @@ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -2700,7 +2735,6 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "license": "MIT", - "optional": true, "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -2750,8 +2784,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/sqlite3": { "version": "5.1.7", diff --git a/packages/backend/package.json b/packages/backend/package.json index 18209d6..ef69a2d 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -13,6 +13,8 @@ "connect-sqlite3": "^0.9.15", "express": "^5.1.0", "express-session": "^1.18.1", + "https-proxy-agent": "^7.0.6", + "socks": "^2.8.4", "sqlite3": "^5.1.7", "ssh2": "^1.16.0", "ws": "^8.18.1" diff --git a/packages/backend/src/connections/connections.controller.ts b/packages/backend/src/connections/connections.controller.ts index 6306764..0c9dfb1 100644 --- a/packages/backend/src/connections/connections.controller.ts +++ b/packages/backend/src/connections/connections.controller.ts @@ -23,7 +23,8 @@ interface ConnectionInfoBase { * 创建新连接 (POST /api/v1/connections) */ export const createConnection = async (req: Request, res: Response): Promise => { - const { name, host, port = 22, username, auth_method, password, private_key, passphrase } = req.body; + // 新增 proxy_id + const { name, host, port = 22, username, auth_method, password, private_key, passphrase, proxy_id } = req.body; const userId = req.session.userId; // 从会话获取用户 ID // 输入验证 (基础) @@ -67,13 +68,14 @@ export const createConnection = async (req: Request, res: Response): Promise((resolve, reject) => { const stmt = db.prepare( - `INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + `INSERT INTO connections (name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` // 添加 proxy_id ); // 注意:这里没有存储 userId,因为 MVP 只有一个用户。如果未来支持多用户,需要添加 user_id 字段。 stmt.run( name, host, port, username, auth_method, encryptedPassword, encryptedPrivateKey, encryptedPassphrase, + proxy_id ?? null, // 如果未提供则设为 null now, now, function (this: Statement, err: Error | null) { if (err) { @@ -87,11 +89,13 @@ export const createConnection = async (req: Request, res: Response): Promise try { // 查询数据库,排除敏感字段 encrypted_password, encrypted_private_key, encrypted_passphrase // 注意:如果未来支持多用户,需要添加 WHERE user_id = ? 条件 - const connections = await new Promise((resolve, reject) => { + // 新增:包含 proxy_id + const connections = await new Promise<(ConnectionInfoBase & { proxy_id: number | null })[]>((resolve, reject) => { db.all( - `SELECT id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at + `SELECT id, name, host, port, username, auth_method, proxy_id, created_at, updated_at, last_connected_at FROM connections - ORDER BY name ASC`, // 按名称排序 - (err, rows: ConnectionInfoBase[]) => { // 使用更新后的接口 + ORDER BY name ASC`, + (err, rows: (ConnectionInfoBase & { proxy_id: number | null })[]) => { if (err) { console.error('查询连接列表时出错:', err.message); return reject(new Error('获取连接列表失败')); @@ -149,13 +154,14 @@ export const getConnectionById = async (req: Request, res: Response): Promise((resolve, reject) => { + // 新增:包含 proxy_id + const connection = await new Promise<(ConnectionInfoBase & { proxy_id: number | null }) | null>((resolve, reject) => { db.get( - `SELECT id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at + `SELECT id, name, host, port, username, auth_method, proxy_id, created_at, updated_at, last_connected_at FROM connections WHERE id = ?`, [connectionId], - (err, row: ConnectionInfoBase) => { // 使用更新后的接口 + (err, row: (ConnectionInfoBase & { proxy_id: number | null })) => { if (err) { console.error(`查询连接 ${connectionId} 时出错:`, err.message); return reject(new Error('获取连接信息失败')); @@ -182,7 +188,8 @@ export const getConnectionById = async (req: Request, res: Response): Promise => { const connectionId = parseInt(req.params.id, 10); - const { name, host, port, username, auth_method, password, private_key, passphrase } = req.body; + // 新增 proxy_id + const { name, host, port, username, auth_method, password, private_key, passphrase, proxy_id } = req.body; const userId = req.session.userId; if (isNaN(connectionId)) { @@ -191,7 +198,8 @@ export const updateConnection = async (req: Request, res: Response): Promise((resolve, reject) => { + // 注意:需要查询加密字段以进行比较和保留 + db.get( + `SELECT id, name, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id + FROM connections + WHERE id = ?`, + [connectionId], + (err, row: any) => { // 使用 any 避免类型冲突,或定义更完整的接口 + if (err) { + console.error(`查询连接 ${connectionId} 时出错:`, err.message); + return reject(new Error('获取连接信息失败')); + } + resolve(row || null); + } + ); + }); + + if (!currentConnection) { + res.status(404).json({ message: '连接未找到。' }); + return; + } + const fieldsToUpdate: { [key: string]: any } = {}; const params: any[] = []; + let newAuthMethod = auth_method || currentConnection.auth_method; // 确定最终的认证方式 - // 构建要更新的字段和参数 + // 构建要更新的非敏感字段和参数 if (name !== undefined) { fieldsToUpdate.name = name; params.push(name); } if (host !== undefined) { fieldsToUpdate.host = host; params.push(host); } if (port !== undefined) { @@ -217,55 +249,69 @@ export const updateConnection = async (req: Request, res: Response): Promise((resolve, reject) => { db.get( - `SELECT id, name, host, port, username, auth_method, created_at, updated_at, last_connected_at + // 新增:包含 proxy_id + `SELECT id, name, host, port, username, auth_method, proxy_id, created_at, updated_at, last_connected_at FROM connections WHERE id = ?`, [connectionId], - (err, row: ConnectionInfoBase) => err ? reject(err) : resolve(row || null) + (err, row: ConnectionInfoBase & { proxy_id: number | null }) => err ? reject(err) : resolve(row || null) ); }); res.status(200).json({ message: '连接更新成功。', connection: updatedConnection }); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index cf276fc..091efa1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -9,9 +9,10 @@ import bcrypt from 'bcrypt'; // 引入 bcrypt 用于哈希密码 import { getDb } from './database'; import { runMigrations } from './migrations'; import authRouter from './auth/auth.routes'; // 导入认证路由 -import connectionsRouter from './connections/connections.routes'; // 导入连接路由 -import sftpRouter from './sftp/sftp.routes'; // 导入 SFTP 路由 -import { initializeWebSocket } from './websocket'; // 导入 WebSocket 初始化函数 +import connectionsRouter from './connections/connections.routes'; +import sftpRouter from './sftp/sftp.routes'; +import proxyRoutes from './proxies/proxies.routes'; // 导入代理路由 +import { initializeWebSocket } from './websocket'; // 基础 Express 应用设置 (后续会扩展) const app = express(); @@ -79,9 +80,10 @@ declare module 'express-session' { const port = process.env.PORT || 3001; // 示例端口,可配置 // --- API 路由 --- -app.use('/api/v1/auth', authRouter); // 挂载认证相关的路由 -app.use('/api/v1/connections', connectionsRouter); // 挂载连接相关的路由 -app.use('/api/v1/sftp', sftpRouter); // 挂载 SFTP 相关的路由 +app.use('/api/v1/auth', authRouter); +app.use('/api/v1/connections', connectionsRouter); +app.use('/api/v1/sftp', sftpRouter); +app.use('/api/v1/proxies', proxyRoutes); // 挂载代理相关的路由 // 状态检查接口 app.get('/api/v1/status', (req: Request, res: Response) => { diff --git a/packages/backend/src/migrations.ts b/packages/backend/src/migrations.ts index ec7092c..bba37b9 100644 --- a/packages/backend/src/migrations.ts +++ b/packages/backend/src/migrations.ts @@ -22,17 +22,32 @@ CREATE TABLE IF NOT EXISTS connections ( username TEXT NOT NULL, auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')), -- 更新 CHECK 约束 encrypted_password TEXT NULL, - encrypted_private_key TEXT NULL, -- 取消注释 - encrypted_passphrase TEXT NULL, -- 取消注释 - -- proxy_id INTEGER NULL, -- 代理相关字段 (暂未实现) + encrypted_private_key TEXT NULL, + encrypted_passphrase TEXT NULL, + proxy_id INTEGER NULL, -- 新增:关联的代理 ID created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - last_connected_at INTEGER NULL + last_connected_at INTEGER NULL, + FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL -- 设置外键约束,删除代理时将关联设为 NULL +); +`; + +// 新增:创建 proxies 表的 SQL +const createProxiesTableSQL = ` +CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('SOCKS5', 'HTTP')), -- 代理类型,目前支持 SOCKS5 和 HTTP + host TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT NULL, -- 代理认证用户名 (可选) + encrypted_password TEXT NULL, -- 加密存储的代理密码 (可选) + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL ); `; // 未来可能需要的其他表 (根据项目文档) -// const createProxiesTableSQL = \`...\`; // 代理表 // const createTagsTableSQL = \`...\`; // 标签表 // const createConnectionTagsTableSQL = \`...\`; // 连接与标签的关联表 // const createSettingsTableSQL = \`...\`; // 设置表 @@ -122,9 +137,22 @@ export const runMigrations = async (db: Database): Promise => { await addColumnIfNotExists(db, 'connections', 'auth_method', "TEXT NOT NULL DEFAULT 'password'"); // Add default for existing rows await addColumnIfNotExists(db, 'connections', 'encrypted_private_key', 'TEXT NULL'); await addColumnIfNotExists(db, 'connections', 'encrypted_passphrase', 'TEXT NULL'); + // 新增:添加 proxy_id 列到 connections 表 (如果不存在) + // 注意:直接添加带 FOREIGN KEY 的列在旧版 SQLite 中可能有限制,但现代版本通常支持。 + // 如果遇到问题,可能需要更复杂的迁移步骤(创建新表,复制数据,重命名)。 + // 这里我们先尝试直接添加。ON DELETE SET NULL 意味着如果代理被删除,关联的连接不会被删除,只是 proxy_id 变为空。 + await addColumnIfNotExists(db, 'connections', 'proxy_id', 'INTEGER NULL REFERENCES proxies(id) ON DELETE SET NULL'); + + // 创建 proxies 表 (如果不存在) + await new Promise((resolve, reject) => { + db.run(createProxiesTableSQL, (err) => { + if (err) return reject(new Error(`创建 proxies 表时出错: ${err.message}`)); + console.log('Proxies 表已检查/创建。'); + resolve(); + }); + }); // Add other tables or columns here in the future - // await addColumnIfNotExists(db, 'connections', 'proxy_id', 'INTEGER NULL'); console.log('数据库迁移检查完成。'); diff --git a/packages/backend/src/proxies/proxies.controller.ts b/packages/backend/src/proxies/proxies.controller.ts new file mode 100644 index 0000000..e62a791 --- /dev/null +++ b/packages/backend/src/proxies/proxies.controller.ts @@ -0,0 +1,232 @@ +import { Request, Response } from 'express'; +import { getDb } from '../database'; +import { encrypt, decrypt } from '../utils/crypto'; // 引入加解密工具 + +// 定义代理信息接口 (用于类型提示) +interface ProxyData { + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string | null; + password?: string | null; // 接收原始密码 +} + +// 获取所有代理配置 (不含密码) +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((resolve, reject) => { + db.all(sql, [], (err, rows) => { + if (err) { + return reject(err); + } + resolve(rows); + }); + }); + res.status(200).json(proxies); + } catch (error: any) { + 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((resolve, reject) => { + db.get(sql, [id], (err, row) => { + if (err) { + return reject(err); + } + resolve(row); // 如果找不到,row 会是 undefined + }); + }); + + if (proxy) { + res.status(200).json(proxy); + } else { + res.status(404).json({ message: `未找到 ID 为 ${id} 的代理` }); + } + } catch (error: any) { + 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); // 加密密码 + } + + 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 }); + }); + }); + + 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')) { + // 可以添加更具体的唯一约束错误处理,例如判断是哪个字段冲突 + return res.status(409).json({ message: '创建代理失败:可能存在同名字段冲突', error: 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 = 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 + } + + // 构建动态 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); + } + + // 总是更新 updated_at 时间戳 + fieldsToUpdate.push('updated_at = ?'); + params.push(now); + + // 添加 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((resolve, reject) => { + db.get(`SELECT id, name, type, host, port, username, created_at, updated_at FROM proxies WHERE id = ?`, [id], (err, row) => { + if (err) return reject(err); + resolve(row); + }); + }); + if (updatedProxy) { + res.status(200).json({ message: '代理更新成功', proxy: updatedProxy }); + } else { + // 理论上更新成功后应该能找到,除非并发删除了 + res.status(404).json({ message: `更新成功,但未能找到 ID 为 ${id} 的代理` }); + } + } else { + // 如果 changes 为 0,说明没有找到对应 ID 的代理 + res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行更新` }); + } + + } catch (error: any) { + if (error.message.includes('UNIQUE constraint failed')) { + return res.status(409).json({ message: '更新代理失败:可能存在同名字段冲突', error: 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 }); + }); + }); + + if (result.changes > 0) { + res.status(200).json({ message: `代理 ${id} 删除成功` }); + } else { + // 如果 changes 为 0,说明没有找到对应 ID 的代理 + res.status(404).json({ message: `未找到 ID 为 ${id} 的代理进行删除` }); + } + } catch (error: any) { + res.status(500).json({ message: `删除代理 ${id} 失败`, error: error.message }); + } +}; diff --git a/packages/backend/src/proxies/proxies.routes.ts b/packages/backend/src/proxies/proxies.routes.ts new file mode 100644 index 0000000..2bb373c --- /dev/null +++ b/packages/backend/src/proxies/proxies.routes.ts @@ -0,0 +1,24 @@ +import express, { RequestHandler } from 'express'; // 引入 RequestHandler +import { isAuthenticated } from '../auth/auth.middleware'; +import { + getAllProxies, + getProxyById, + createProxy, + updateProxy, + deleteProxy +} from './proxies.controller'; // 引入控制器函数 + +const router = express.Router(); + +// 应用认证中间件到所有代理路由 +router.use(isAuthenticated); + +// 定义代理 CRUD 路由 +// 显式类型断言以解决潜在的类型不匹配问题 +router.get('/', getAllProxies as RequestHandler); +router.get('/:id', getProxyById as RequestHandler); +router.post('/', createProxy as RequestHandler); +router.put('/:id', updateProxy as RequestHandler); // 类型断言 +router.delete('/:id', deleteProxy as RequestHandler); // 类型断言 + +export default router; diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 728e172..1ecd85e 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -6,6 +6,8 @@ import { WriteStream } from 'fs'; // 需要 WriteStream 类型 (虽然 ssh2 的 import { getDb } from './database'; // 引入数据库实例 import { decrypt } from './utils/crypto'; // 引入解密函数 import path from 'path'; // 需要 path +import { HttpsProxyAgent } from 'https-proxy-agent'; // 引入 HTTP 代理支持 +import { SocksClient } from 'socks'; // 引入 SOCKS 代理支持 // 扩展 WebSocket 类型以包含会话和 SSH/SFTP 连接信息 interface AuthenticatedWebSocket extends WebSocket { @@ -25,21 +27,32 @@ export const activeSshConnections = new Map(); -// 数据库连接信息接口 (包含所有可能的凭证字段) +// 数据库连接信息接口 (包含所有可能的凭证字段和 proxy_id) interface DbConnectionInfo { id: number; name: string; host: string; port: number; username: string; - auth_method: 'password' | 'key'; // 支持密码或密钥 + auth_method: 'password' | 'key'; encrypted_password?: string | null; encrypted_private_key?: string | null; encrypted_passphrase?: string | null; - // proxy_id: number | null; // 待添加代理支持 + proxy_id?: number | null; // 关联的代理 ID // 其他字段... } +// 新增:数据库代理信息接口 +interface DbProxyInfo { + id: number; + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string | null; + encrypted_password?: string | null; +} + /** * 清理指定 WebSocket 连接关联的 SSH 资源 @@ -63,7 +76,7 @@ const cleanupSshConnection = (ws: AuthenticatedWebSocket) => { }; // --- 状态获取相关 --- -const STATUS_POLL_INTERVAL = 5000; // 每 5 秒获取一次状态 +const STATUS_POLL_INTERVAL = 1000; // 每 5 秒获取一次状态 // Helper function to execute a command and return its stdout const executeSshCommand = (client: Client, command: string): Promise => { @@ -494,15 +507,14 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH console.log(`WebSocket: 用户 ${ws.username} 请求连接到 ID: ${connectionId}`); ws.send(JSON.stringify({ type: 'ssh:status', payload: '正在获取连接信息...' })); - // 1. 从数据库获取连接信息 (包括所有凭证字段) + // 1. 从数据库获取连接信息 (包括 proxy_id) const connInfo = await new Promise((resolve, reject) => { - // 注意:如果多用户,需要验证 connectionId 是否属于当前 userId db.get( - `SELECT id, name, host, port, username, auth_method, + `SELECT id, name, host, port, username, auth_method, proxy_id, encrypted_password, encrypted_private_key, encrypted_passphrase - FROM connections WHERE id = ?`, + FROM connections WHERE id = ?`, // 添加 proxy_id [connectionId], - (err, row: DbConnectionInfo) => { + (err, row: DbConnectionInfo) => { // 类型已更新 if (err) { console.error(`查询连接 ${connectionId} 详细信息时出错:`, err); return reject(new Error('查询连接信息失败')); @@ -523,9 +535,35 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH // return; } + // 2. 获取代理信息 (如果 connInfo.proxy_id 存在) + let proxyInfo: DbProxyInfo | null = null; + if (connInfo.proxy_id) { + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在获取代理 ${connInfo.proxy_id} 信息...` })); + try { + proxyInfo = await new Promise((resolve, reject) => { + db.get( + `SELECT id, name, type, host, port, username, encrypted_password FROM proxies WHERE id = ?`, + [connInfo.proxy_id], + (err, row: DbProxyInfo) => { + if (err) return reject(new Error(`查询代理 ${connInfo.proxy_id} 失败: ${err.message}`)); + resolve(row ?? null); + } + ); + }); + if (!proxyInfo) { + throw new Error(`未找到 ID 为 ${connInfo.proxy_id} 的代理配置。`); + } + console.log(`使用代理: ${proxyInfo.name} (${proxyInfo.type})`); + } catch (proxyError: any) { + console.error(`获取代理信息失败:`, proxyError); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `获取代理信息失败: ${proxyError.message}` })); + return; // 获取代理失败则停止连接 + } + } + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在连接到 ${connInfo.host}...` })); - // 2. 解密凭证并构建连接配置 + // 3. 解密凭证并构建连接配置 let connectConfig: any = { host: connInfo.host, port: connInfo.port, @@ -558,97 +596,87 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH return; } - // 3. 建立 SSH 连接 - const sshClient = new Client(); - ws.sshClient = sshClient; // 关联 client + // 4. 处理代理配置(如果存在)并建立连接 + const sshClient = new Client(); // 创建 SSH Client 实例 - sshClient.on('ready', () => { - console.log(`SSH: 用户 ${ws.username} 到 ${connInfo.host} 连接成功!`); - ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' })); - - // 4. 请求 Shell 通道 - sshClient.shell((err, stream) => { - if (err) { - console.error(`SSH: 用户 ${ws.username} 打开 Shell 失败:`, err); - ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${err.message}` })); - cleanupSshConnection(ws); - return; + if (proxyInfo) { + console.log(`WebSocket: 检测到连接 ${connInfo.id} 使用代理 ${proxyInfo.id} (${proxyInfo.type})`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在应用代理 ${proxyInfo.name}...` })); + try { + let proxyPassword = ''; + if (proxyInfo.encrypted_password) { + proxyPassword = decrypt(proxyInfo.encrypted_password); } - ws.sshShellStream = stream; // 关联 stream - // 存储活动连接 (此时 sftp 可能还未就绪) - activeSshConnections.set(ws, { client: sshClient, shell: stream }); - console.log(`SSH: 用户 ${ws.username} Shell 通道已打开。`); - // 尝试初始化 SFTP 会话 - sshClient.sftp((sftpErr, sftp) => { - if (sftpErr) { - console.error(`SFTP: 用户 ${ws.username} 初始化失败:`, sftpErr); - // 即使 SFTP 失败,也保持 Shell 连接,但发送错误通知 - ws.send(JSON.stringify({ type: 'sftp:error', payload: `SFTP 初始化失败: ${sftpErr.message}` })); - // 不再发送 ssh:connected,因为 SFTP 也是核心功能的一部分 - // ws.send(JSON.stringify({ type: 'ssh:connected' })); - // 可以在这里发送一个包含错误的状态 - ws.send(JSON.stringify({ type: 'ssh:status', payload: 'Shell 已连接,但 SFTP 初始化失败。' })); - return; + if (proxyInfo.type === 'SOCKS5') { + const socksOptions = { + proxy: { + host: proxyInfo.host, + port: proxyInfo.port, + type: 5 as 5, // SOCKS 版本 5 + userId: proxyInfo.username || undefined, + password: proxyPassword || undefined, + }, + command: 'connect' as 'connect', + destination: { + host: connInfo.host, + port: connInfo.port, + }, + timeout: connectConfig.readyTimeout ?? 20000, // 使用连接超时时间 + }; + console.log(`WebSocket: 正在通过 SOCKS5 代理 ${proxyInfo.host}:${proxyInfo.port} 连接到目标 ${connInfo.host}:${connInfo.port}...`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在通过 SOCKS5 代理 ${proxyInfo.name} 连接...` })); + + SocksClient.createConnection(socksOptions) + .then(({ socket }) => { + console.log(`WebSocket: SOCKS5 代理连接成功。正在建立 SSH 连接...`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SOCKS5 代理连接成功,正在建立 SSH...' })); + connectConfig.sock = socket; // 使用建立的 SOCKS socket + connectSshClient(ws, sshClient, connectConfig, connInfo); // 通过代理连接 SSH + }) + .catch(socksError => { + console.error(`WebSocket: SOCKS5 代理连接失败:`, socksError); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `SOCKS5 代理连接失败: ${socksError.message}` })); + cleanupSshConnection(ws); + }); + // 注意:对于 SOCKS5,连接逻辑在 .then 回调中处理 + + } else if (proxyInfo.type === 'HTTP') { + let proxyUrl = `http://`; + if (proxyInfo.username) { + proxyUrl += `${proxyInfo.username}`; + if (proxyPassword) { + proxyUrl += `:${proxyPassword}`; + } + proxyUrl += '@'; } - console.log(`SFTP: 用户 ${ws.username} 会话已初始化。`); - // 将 SFTP 实例存入 Map - const existingConn = activeSshConnections.get(ws); - if (existingConn) { - existingConn.sftp = sftp; - // SFTP 就绪后,才真正通知前端连接完成 - ws.send(JSON.stringify({ type: 'ssh:connected' })); - // 启动状态轮询 - startStatusPolling(ws, sshClient); - } else { - // This case should ideally not happen if the connection was set earlier - console.error(`SFTP: 无法找到用户 ${ws.username} 的活动连接记录以存储 SFTP 或启动轮询。`); - ws.send(JSON.stringify({ type: 'ssh:error', payload: '内部服务器错误:无法关联 SFTP 会话。' })); - cleanupSshConnection(ws); - } - }); - - // 5. 数据转发:Shell -> WebSocket (发送 Base64 编码的数据) - stream.on('data', (data: Buffer) => { - // console.log('SSH Output Buffer Length:', data.length); // Debug log - ws.send(JSON.stringify({ - type: 'ssh:output', - payload: data.toString('base64'), // 将 Buffer 转为 Base64 字符串 - encoding: 'base64' // 明确告知前端编码方式 - })); - }); - - // 6. 处理 Shell 关闭 - stream.on('close', () => { - console.log(`SSH: 用户 ${ws.username} Shell 通道已关闭。`); - ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' })); - cleanupSshConnection(ws); // 清理资源 - }); - // Stderr 也使用 Base64 发送 - stream.stderr.on('data', (data: Buffer) => { - console.error(`SSH Stderr (${ws.username}): ${data.toString('utf8').substring(0,100)}...`); // 日志中尝试 utf8 解码预览 - ws.send(JSON.stringify({ - type: 'ssh:output', // 同样使用 ssh:output 类型 - payload: data.toString('base64'), - encoding: 'base64' - })); - }); - }); - }).on('error', (err) => { - console.error(`SSH: 用户 ${ws.username} 连接错误:`, err); - ws.send(JSON.stringify({ type: 'ssh:error', payload: `SSH 连接错误: ${err.message}` })); - cleanupSshConnection(ws); - }).on('close', () => { - console.log(`SSH: 用户 ${ws.username} 连接已关闭。`); - // 确保即使 shell 没关闭,也要通知前端并清理 - if (activeSshConnections.has(ws)) { - ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'SSH 连接已关闭。' })); + proxyUrl += `${proxyInfo.host}:${proxyInfo.port}`; + console.log(`WebSocket: 为连接 ${connInfo.id} 配置 HTTP 代理: ${proxyUrl.replace(/:[^:]*@/, ':***@')}`); + connectConfig.agent = new HttpsProxyAgent(proxyUrl); + console.log(`WebSocket: 已配置 HTTP 代理。正在建立 SSH 连接...`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在通过 HTTP 代理 ${proxyInfo.name} 连接...` })); + connectSshClient(ws, sshClient, connectConfig, connInfo); // 通过代理连接 SSH + } else { + console.error(`WebSocket: 未知的代理类型: ${proxyInfo.type}`); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `未知的代理类型: ${proxyInfo.type}` })); + cleanupSshConnection(ws); + } + } catch (proxyProcessError: any) { + console.error(`处理代理 ${proxyInfo.id} 配置或凭证失败:`, proxyProcessError); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `无法处理代理配置: ${proxyProcessError.message}` })); cleanupSshConnection(ws); } - }).connect(connectConfig); // 使用前面构建的 connectConfig 对象 + } else { + // 5. 无代理,直接连接 + console.log(`WebSocket: 未配置代理。正在直接建立 SSH 连接...`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: `正在直接连接到 ${connInfo.host}...` })); + connectSshClient(ws, sshClient, connectConfig, connInfo); // 直接连接 SSH + } break; } // end case 'ssh:connect' + // --- 处理 SSH 输入 --- + // --- 处理 SSH 输入 --- case 'ssh:input': { const connection = activeSshConnections.get(ws); @@ -1146,3 +1174,101 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH console.log('WebSocket 服务器初始化完成。'); return wss; }; + +// --- 辅助函数:建立 SSH 连接并处理事件 --- +function connectSshClient(ws: AuthenticatedWebSocket, sshClient: Client, connectConfig: any, connInfo: DbConnectionInfo) { + ws.sshClient = sshClient; // 关联 client + + sshClient.on('ready', () => { + console.log(`SSH: 用户 ${ws.username} 到 ${connInfo.host} 连接成功!`); + ws.send(JSON.stringify({ type: 'ssh:status', payload: 'SSH 连接成功,正在打开 Shell...' })); + + // 请求 Shell 通道 + sshClient.shell((err, stream) => { + if (err) { + console.error(`SSH: 用户 ${ws.username} 打开 Shell 失败:`, err); + ws.send(JSON.stringify({ type: 'ssh:error', payload: `打开 Shell 失败: ${err.message}` })); + cleanupSshConnection(ws); + return; + } + ws.sshShellStream = stream; // 关联 stream + // 存储活动连接 (此时 sftp 可能还未就绪) + // 确保 client 和 shell 都存在才存储 + if (activeSshConnections.has(ws)) { + // 如果已存在(例如 SOCKS 连接后),更新 shell + const existing = activeSshConnections.get(ws)!; + existing.shell = stream; + } else { + activeSshConnections.set(ws, { client: sshClient, shell: stream }); + } + console.log(`SSH: 用户 ${ws.username} Shell 通道已打开。`); + + // 尝试初始化 SFTP 会话 + sshClient.sftp((sftpErr, sftp) => { + if (sftpErr) { + console.error(`SFTP: 用户 ${ws.username} 初始化失败:`, sftpErr); + ws.send(JSON.stringify({ type: 'sftp:error', payload: `SFTP 初始化失败: ${sftpErr.message}` })); + ws.send(JSON.stringify({ type: 'ssh:status', payload: 'Shell 已连接,但 SFTP 初始化失败。' })); + // SFTP 失败不应断开整个连接,但需要标记 + const existingConn = activeSshConnections.get(ws); + if (existingConn) { + // SFTP 失败,但 Shell 仍可用,启动状态轮询 + startStatusPolling(ws, sshClient); + } + return; + } + console.log(`SFTP: 用户 ${ws.username} 会话已初始化。`); + const existingConn = activeSshConnections.get(ws); + if (existingConn) { + existingConn.sftp = sftp; + ws.send(JSON.stringify({ type: 'ssh:connected' })); // SFTP 就绪后通知前端 + startStatusPolling(ws, sshClient); // 启动状态轮询 + } else { + console.error(`SFTP: 无法找到用户 ${ws.username} 的活动连接记录以存储 SFTP 或启动轮询。`); + ws.send(JSON.stringify({ type: 'ssh:error', payload: '内部服务器错误:无法关联 SFTP 会话。' })); + cleanupSshConnection(ws); + } + }); + + // 数据转发:Shell -> WebSocket + stream.on('data', (data: Buffer) => { + ws.send(JSON.stringify({ + type: 'ssh:output', + payload: data.toString('base64'), + encoding: 'base64' + })); + }); + + // 处理 Shell 关闭 + stream.on('close', () => { + console.log(`SSH: 用户 ${ws.username} Shell 通道已关闭。`); + ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'Shell 通道已关闭。' })); + cleanupSshConnection(ws); + }); + // Stderr 转发 + stream.stderr.on('data', (data: Buffer) => { + console.error(`SSH Stderr (${ws.username}): ${data.toString('utf8').substring(0,100)}...`); + ws.send(JSON.stringify({ + type: 'ssh:output', + payload: data.toString('base64'), + encoding: 'base64' + })); + }); + }); + }).on('error', (err) => { + console.error(`SSH: 用户 ${ws.username} 连接错误:`, err); + // 避免在 SOCKS 错误后重复发送错误 + if (!ws.CLOSED && !ws.CLOSING) { // 检查 WebSocket 状态 + ws.send(JSON.stringify({ type: 'ssh:error', payload: `SSH 连接错误: ${err.message}` })); + } + cleanupSshConnection(ws); + }).on('close', () => { + console.log(`SSH: 用户 ${ws.username} 连接已关闭。`); + if (activeSshConnections.has(ws)) { + if (!ws.CLOSED && !ws.CLOSING) { + ws.send(JSON.stringify({ type: 'ssh:disconnected', payload: 'SSH 连接已关闭。' })); + } + cleanupSshConnection(ws); + } + }).connect(connectConfig); +} diff --git a/packages/data/nexus-terminal.db b/packages/data/nexus-terminal.db index d2f3dd6f226a2413864c79fc4d115d2f47bba2ce..a2d9d8876704822712b8075a5a9ce7d739530ff9 100644 GIT binary patch delta 650 zcmZp8z}V2hG(k#;k&%IcfdhzPfPJElkql5&uV@i3*AfPH)<6dSV*cZN`urZecUc2D zPH$|y&cfAJ&c-gTtjySJx_KR!9&^2pLP1e}MP+r-tD9?( ztDm!LFjzrmYOzLUil&0UpMs04k86mlLa=KHSfyrj7(2VTv@~Nob4g-SPAbSaplT?^ z;T+`Z2vi;7=;Y%HGf6>%O92Q#R!)A)WyexdS&%w;0=H-ghyiqlf`)pqzq5C+sk)AW zx<^PzfV!rpj)JbP!js*LpHFCezH#@{dEM{#^gN%v?rCT5vnlJJ^)xC#bSONX)T02> z0=6L|zqn*_A-Bk6XKqnz9k5VoacWUsVs5HJh-(DI14t&nShwWG`o`x|Ry^z2{&YgG zg2vPS^)DMbH77siHkE}~{&aoY^97A?8L*kjMX8hHc;zN*^UJdE|6sPWj_zE@)3MBDO zF38IftY+e85Z7cBm2DJdVFAW4=j69}3OH2;yzIi@=uBJgnd0{SQfsvL~`DN~Y zxw+cL$tA&sc^;M_xyGJ_#)-vMMwuQNWIQG=0-K~PkaiGhKIr8pfFl7?pHdgg|DMg|soh9;Kv O-RXa)1g8I;6%7C)*V!Tf delta 221 zcmZo@U}|{4I6+E?VFLpL13M7I0P92@BPpP$UeO|6{vQl%TuT`Ei}{c9>GON=-sM`d zSx`WSi>1wsjeYY9uKgk`(>}O=*qkjt(}07C|0x6iH~y!a1s!hkPktJ&$Z?;6Ka%f0 zUja~065r&4yd1&5?(-{{co@Vr8AW9qMOj!FCcn*7@JTdFF9 { diff --git a/packages/frontend/src/components/AddConnectionForm.vue b/packages/frontend/src/components/AddConnectionForm.vue index feb7137..e734167 100644 --- a/packages/frontend/src/components/AddConnectionForm.vue +++ b/packages/frontend/src/components/AddConnectionForm.vue @@ -1,10 +1,11 @@ + + + + diff --git a/packages/frontend/src/components/ProxyList.vue b/packages/frontend/src/components/ProxyList.vue new file mode 100644 index 0000000..6869a32 --- /dev/null +++ b/packages/frontend/src/components/ProxyList.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 58e07e8..2dff7b3 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -3,6 +3,7 @@ "nav": { "dashboard": "Dashboard", "connections": "Connections", + "proxies": "Proxies", "login": "Login", "logout": "Logout" }, @@ -53,13 +54,17 @@ "errorRequiredFields": "Please fill in all required fields.", "errorPasswordRequired": "Password is required for password authentication.", "errorPrivateKeyRequired": "Private key is required for key authentication.", + "errorPasswordRequiredOnSwitch": "Password is required when switching to password authentication.", + "errorPrivateKeyRequiredOnSwitch": "Private key is required when switching to key authentication.", "errorPort": "Port must be between 1 and 65535.", "errorAdd": "Failed to add connection: {error}", "titleEdit": "Edit Connection", "confirmEdit": "Confirm Edit", "saving": "Saving...", "errorUpdate": "Failed to update connection: {error}", - "keyUpdateNote": "Leave private key and passphrase blank to keep the existing key." + "keyUpdateNote": "Leave private key and passphrase blank to keep the existing key.", + "proxy": "Proxy:", + "noProxy": "No Proxy" }, "prompts": { "confirmDelete": "Are you sure you want to delete the connection \"{name}\"? This cannot be undone." @@ -71,6 +76,53 @@ "never": "Never" } }, + "proxies": { + "title": "Proxy Management", + "addProxy": "Add New Proxy", + "loading": "Loading proxies...", + "error": "Failed to load proxies: {error}", + "noProxies": "No proxies yet. Click 'Add New Proxy' to create one!", + "table": { + "name": "Name", + "type": "Type", + "host": "Host", + "port": "Port", + "user": "User", + "updatedAt": "Updated At", + "actions": "Actions" + }, + "actions": { + "edit": "Edit", + "delete": "Delete" + }, + "form": { + "title": "Add New Proxy", + "titleEdit": "Edit Proxy", + "name": "Name:", + "type": "Type:", + "host": "Host/IP:", + "port": "Port:", + "username": "Username:", + "password": "Password:", + "optional": "Optional", + "confirm": "Confirm Add", + "confirmEdit": "Confirm Edit", + "adding": "Adding...", + "saving": "Saving...", + "cancel": "Cancel", + "errorRequiredFields": "Please fill in all required fields.", + "errorPort": "Port must be between 1 and 65535.", + "errorAdd": "Failed to add proxy: {error}", + "errorUpdate": "Failed to update proxy: {error}", + "passwordUpdateNote": "Leave password blank to keep the existing password." + }, + "prompts": { + "confirmDelete": "Are you sure you want to delete the proxy \"{name}\"? This cannot be undone." + }, + "errors": { + "deleteFailed": "Failed to delete proxy: {error}" + } + }, "workspace": { "statusBar": "Status: {status} (Connection ID: {id})", "status": { diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index 25b56a0..8eab0ca 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -3,6 +3,7 @@ "nav": { "dashboard": "仪表盘", "connections": "连接管理", + "proxies": "代理管理", "login": "登录", "logout": "登出" }, @@ -53,13 +54,17 @@ "errorRequiredFields": "请填写所有必填字段。", "errorPasswordRequired": "使用密码认证时,密码为必填项。", "errorPrivateKeyRequired": "使用密钥认证时,私钥为必填项。", + "errorPasswordRequiredOnSwitch": "切换到密码认证时,密码为必填项。", + "errorPrivateKeyRequiredOnSwitch": "切换到密钥认证时,私钥为必填项。", "errorPort": "端口号必须在 1 到 65535 之间。", "errorAdd": "添加连接失败: {error}", "titleEdit": "编辑连接", "confirmEdit": "确认编辑", "saving": "正在保存...", "errorUpdate": "更新连接失败: {error}", - "keyUpdateNote": "将私钥和密码短语留空以保留现有密钥。" + "keyUpdateNote": "将私钥和密码短语留空以保留现有密钥。", + "proxy": "代理:", + "noProxy": "无代理" }, "prompts": { "confirmDelete": "确定要删除连接 \"{name}\" 吗?此操作不可撤销。" @@ -71,6 +76,53 @@ "never": "从未" } }, + "proxies": { + "title": "代理管理", + "addProxy": "添加新代理", + "loading": "正在加载代理...", + "error": "加载代理列表失败: {error}", + "noProxies": "还没有任何代理配置。点击“添加新代理”来创建一个吧!", + "table": { + "name": "名称", + "type": "类型", + "host": "主机", + "port": "端口", + "user": "用户名", + "updatedAt": "更新时间", + "actions": "操作" + }, + "actions": { + "edit": "编辑", + "delete": "删除" + }, + "form": { + "title": "添加新代理", + "titleEdit": "编辑代理", + "name": "名称:", + "type": "类型:", + "host": "主机/IP:", + "port": "端口:", + "username": "用户名:", + "password": "密码:", + "optional": "可选", + "confirm": "确认添加", + "confirmEdit": "确认编辑", + "adding": "正在添加...", + "saving": "正在保存...", + "cancel": "取消", + "errorRequiredFields": "请填写所有必填字段。", + "errorPort": "端口号必须在 1 到 65535 之间。", + "errorAdd": "添加代理失败: {error}", + "errorUpdate": "更新代理失败: {error}", + "passwordUpdateNote": "将密码留空以保留现有密码。" + }, + "prompts": { + "confirmDelete": "确定要删除代理 \"{name}\" 吗?此操作不可撤销。" + }, + "errors": { + "deleteFailed": "删除代理失败: {error}" + } + }, "workspace": { "statusBar": "状态: {status} (连接 ID: {id})", "status": { diff --git a/packages/frontend/src/router/index.ts b/packages/frontend/src/router/index.ts index 7eae70a..b0ce005 100644 --- a/packages/frontend/src/router/index.ts +++ b/packages/frontend/src/router/index.ts @@ -22,6 +22,12 @@ const routes: Array = [ name: 'Connections', component: () => import('../views/ConnectionsView.vue') }, + // 新增:代理管理页面 + { + path: '/proxies', + name: 'Proxies', + component: () => import('../views/ProxiesView.vue') + }, // 工作区页面,需要 connectionId 参数 { path: '/workspace/:connectionId', // 使用动态路由段 @@ -42,7 +48,9 @@ router.beforeEach((to, from, next) => { // 在守卫内部获取 store 实例,确保 Pinia 已初始化 const authStore = useAuthStore(); - const requiresAuth = !['Login'].includes(to.name as string); // 需要认证的路由 (除了登录页) + // 定义不需要认证的路由名称列表 + const publicRoutes = ['Login']; + const requiresAuth = !publicRoutes.includes(to.name as string); if (requiresAuth && !authStore.isAuthenticated) { // 如果需要认证但用户未登录,重定向到登录页 diff --git a/packages/frontend/src/stores/connections.store.ts b/packages/frontend/src/stores/connections.store.ts index a4a2881..b261d2e 100644 --- a/packages/frontend/src/stores/connections.store.ts +++ b/packages/frontend/src/stores/connections.store.ts @@ -8,7 +8,8 @@ export interface ConnectionInfo { host: string; port: number; username: string; - auth_method: 'password' | 'key'; // 允许 key 类型 + auth_method: 'password' | 'key'; + proxy_id?: number | null; // 新增:关联的代理 ID (可选) created_at: number; updated_at: number; last_connected_at: number | null; @@ -58,11 +59,12 @@ export const useConnectionsStore = defineStore('connections', { port: number; username: string; auth_method: 'password' | 'key'; - password?: string; // 密码变为可选 - private_key?: string; // 私钥是可选的 (仅在 auth_method 为 key 时需要) - passphrase?: string; // 私钥密码是可选的 + password?: string; + private_key?: string; + passphrase?: string; + proxy_id?: number | null; // 新增:允许传入 proxy_id }) { - this.isLoading = true; // 可以为添加操作单独设置加载状态,或共用 isLoading + this.isLoading = true; this.error = null; try { const response = await axios.post<{ message: string; connection: ConnectionInfo }>('/api/v1/connections', newConnectionData); @@ -82,7 +84,8 @@ export const useConnectionsStore = defineStore('connections', { }, // 更新连接 Action - async updateConnection(connectionId: number, updatedData: Partial & { password?: string; private_key?: string; passphrase?: string }>) { + // 更新参数类型以包含 proxy_id + async updateConnection(connectionId: number, updatedData: Partial & { password?: string; private_key?: string; passphrase?: string; proxy_id?: number | null }>) { this.isLoading = true; this.error = null; try { diff --git a/packages/frontend/src/stores/proxies.store.ts b/packages/frontend/src/stores/proxies.store.ts new file mode 100644 index 0000000..7057a61 --- /dev/null +++ b/packages/frontend/src/stores/proxies.store.ts @@ -0,0 +1,129 @@ +import { defineStore } from 'pinia'; +import axios from 'axios'; + +// 定义代理信息接口 (前端使用,不含密码) +export interface ProxyInfo { + id: number; + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string | null; + created_at: number; + updated_at: number; +} + +// 定义 Store State 的接口 +interface ProxiesState { + proxies: ProxyInfo[]; + isLoading: boolean; + error: string | null; +} + +// 定义 Pinia Store +export const useProxiesStore = defineStore('proxies', { + state: (): ProxiesState => ({ + proxies: [], + isLoading: false, + error: null, + }), + actions: { + // 获取代理列表 Action + async fetchProxies() { + this.isLoading = true; + this.error = null; + try { + const response = await axios.get('/api/v1/proxies'); + this.proxies = response.data; + } catch (err: any) { + console.error('获取代理列表失败:', err); + this.error = err.response?.data?.message || err.message || '获取代理列表时发生未知错误。'; + if (err.response?.status === 401) { + console.warn('未授权,需要登录才能获取代理列表。'); + // TODO: 处理未授权情况 + } + } finally { + this.isLoading = false; + } + }, + + // 添加新代理 Action + async addProxy(newProxyData: { + name: string; + type: 'SOCKS5' | 'HTTP'; + host: string; + port: number; + username?: string | null; + password?: string | null; // 包含原始密码 + }) { + this.isLoading = true; + this.error = null; + try { + const response = await axios.post<{ message: string; proxy: ProxyInfo }>('/api/v1/proxies', newProxyData); + this.proxies.unshift(response.data.proxy); // 将新代理添加到列表开头 + return true; // 成功 + } catch (err: any) { + console.error('添加代理失败:', err); + this.error = err.response?.data?.message || err.message || '添加代理时发生未知错误。'; + if (err.response?.status === 401) { + console.warn('未授权,需要登录才能添加代理。'); + } + if (err.response?.status === 409) { + console.warn('添加代理冲突:', err.response?.data?.message); + } + return false; // 失败 + } finally { + this.isLoading = false; + } + }, + + // 更新代理 Action + async updateProxy(proxyId: number, updatedData: Partial) { + this.isLoading = true; + this.error = null; + try { + const response = await axios.put<{ message: string; proxy: ProxyInfo }>(`/api/v1/proxies/${proxyId}`, updatedData); + const index = this.proxies.findIndex(p => p.id === proxyId); + if (index !== -1) { + // 使用返回的更新后的信息替换旧信息 + this.proxies[index] = { ...this.proxies[index], ...response.data.proxy }; + } else { + await this.fetchProxies(); // 如果本地找不到,重新获取列表 + } + return true; // 成功 + } catch (err: any) { + console.error(`更新代理 ${proxyId} 失败:`, err); + this.error = err.response?.data?.message || err.message || '更新代理时发生未知错误。'; + if (err.response?.status === 401) { + console.warn('未授权,需要登录才能更新代理。'); + } + if (err.response?.status === 409) { + console.warn('更新代理冲突:', err.response?.data?.message); + } + return false; // 失败 + } finally { + this.isLoading = false; + } + }, + + // 删除代理 Action + async deleteProxy(proxyId: number) { + this.isLoading = true; + this.error = null; + try { + await axios.delete(`/api/v1/proxies/${proxyId}`); + this.proxies = this.proxies.filter(p => p.id !== proxyId); // 从列表中移除 + return true; // 成功 + } catch (err: any) { + console.error(`删除代理 ${proxyId} 失败:`, err); + this.error = err.response?.data?.message || err.message || '删除代理时发生未知错误。'; + if (err.response?.status === 401) { + console.warn('未授权,需要登录才能删除代理。'); + } + return false; // 失败 + } finally { + this.isLoading = false; + } + }, + }, +}); diff --git a/packages/frontend/src/views/ProxiesView.vue b/packages/frontend/src/views/ProxiesView.vue new file mode 100644 index 0000000..b4fa658 --- /dev/null +++ b/packages/frontend/src/views/ProxiesView.vue @@ -0,0 +1,81 @@ + + + + +