From d1f4561dabd7b2b6c7d07eece6f6ad8d3318e13f Mon Sep 17 00:00:00 2001 From: chaos-zhu Date: Mon, 25 May 2026 21:52:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96server=E7=AB=AF?= =?UTF-8?q?=E4=BB=A3=E7=A0=81&mobile=E7=AB=AF=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/servers/server_repository.dart | 2 +- mobile/lib/l10n/strings_zh.dart | 2 +- server/app/controller/mobile.js | 70 ++---- server/app/controller/sftp.js | 24 ++ server/app/router/routes.js | 69 +++--- server/app/socket/terminal.js | 2 +- server/app/utils/mobile-crypto.js | 44 ---- server/test/test-mobile-crypto.js | 25 --- server/test/test-mobile-ssh-payload.js | 205 ------------------ 9 files changed, 87 insertions(+), 356 deletions(-) create mode 100644 server/app/controller/sftp.js delete mode 100644 server/app/utils/mobile-crypto.js delete mode 100644 server/test/test-mobile-crypto.js delete mode 100644 server/test/test-mobile-ssh-payload.js diff --git a/mobile/lib/features/servers/server_repository.dart b/mobile/lib/features/servers/server_repository.dart index e8c6145..637a347 100644 --- a/mobile/lib/features/servers/server_repository.dart +++ b/mobile/lib/features/servers/server_repository.dart @@ -157,7 +157,7 @@ class ApiServerRepository implements ServerRepository { @override Future> fetchSftpFavorites(String hostId) async { - final response = await _api.getJson('/mobile/sftp-favorites/$hostId'); + final response = await _api.getJson('/sftp/favorites/$hostId'); final raw = response['data']; if (raw is! List) return const []; return raw diff --git a/mobile/lib/l10n/strings_zh.dart b/mobile/lib/l10n/strings_zh.dart index db97a71..aafbb03 100644 --- a/mobile/lib/l10n/strings_zh.dart +++ b/mobile/lib/l10n/strings_zh.dart @@ -37,7 +37,7 @@ const Map stringsZh = { 'login.submit': '登录', 'login.failed': '登录失败', 'login.httpRiskTitle': 'HTTP 连接未加密', - 'login.httpRiskBody': '您的所有数据可能会被盗取,建议使用 HTTPS(内网环境除外)。是否继续?', + 'login.httpRiskBody': 'http协议下存在数据泄露风险,公网使用请配置https协议。是否继续?', 'login.errEmptyUsername': '请输入用户名', 'login.errEmptyPassword': '请输入密码', 'login.errInvalidServer': '请输入有效的服务端地址', diff --git a/server/app/controller/mobile.js b/server/app/controller/mobile.js index 410e57b..899d91a 100644 --- a/server/app/controller/mobile.js +++ b/server/app/controller/mobile.js @@ -1,16 +1,22 @@ +const crypto = require('crypto') const { RSADecryptAsync } = require('../utils/encrypt') -const { encryptJsonForMobile } = require('../utils/mobile-crypto') -const { HostListDB, FavoriteSftpDB } = require('../utils/db-class') -// `getConnectionOptions` and `getProxyConfig` are lazily required inside -// functions. Loading `../socket/terminal` at module scope pulls in -// `terminal-session`, which expects `global.logger` to exist after app boot. -const hostListDB = new HostListDB().getInstance() -const favoriteSftpDB = new FavoriteSftpDB().getInstance() +function encryptJsonForMobile(payload, key) { + if (!Buffer.isBuffer(key) || key.length !== 32) { + throw new Error('temporary key must be 32 bytes') + } + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const plaintext = Buffer.from(JSON.stringify(payload), 'utf8') + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const tag = cipher.getAuthTag() -function normalizePort(port) { - const numericPort = Number(port) - return Number.isFinite(numericPort) && numericPort > 0 ? numericPort : 22 + return { + alg: 'AES-256-GCM', + iv: iv.toString('base64'), + tag: tag.toString('base64'), + ciphertext: ciphertext.toString('base64') + } } function normalizeMobileAuthPayload(hostId, name, authInfo = {}) { @@ -23,7 +29,7 @@ function normalizeMobileAuthPayload(hostId, name, authInfo = {}) { hostId, name, host: host || '', - port: normalizePort(port), + port: Number(port), username: username || '', authType, password: authType === 'password' ? authInfo.password || '' : '', @@ -42,19 +48,14 @@ function normalizeMobileProxy(proxy = {}) { name: proxy.name || '', type: proxy.type, host: proxy.host || '', - port: normalizePort(proxy.port), + port: Number(proxy.port), username: proxy.username || '', password: proxy.password || '' } } -function normalizeMobileJumpHost(jumpHost) { - const authInfo = jumpHost.authInfo || jumpHost - return normalizeMobileAuthPayload( - jumpHost.hostId || jumpHost._id || authInfo.hostId || authInfo._id, - jumpHost.name || authInfo.name, - authInfo - ) +function normalizeMobileJumpHost({ hostId, name, ...authInfo }) { + return normalizeMobileAuthPayload(hostId, name, authInfo) } function toMobileSshPayload(hostId, name, authInfo, topology = {}) { @@ -139,11 +140,7 @@ async function getMobileSshConnection({ request, res }) { const tempKeyText = await RSADecryptAsync(encryptedKey) const tempKey = Buffer.from(tempKeyText, 'base64') const { getConnectionOptions } = require('../socket/terminal') - const { authInfo, name } = await getConnectionOptions(hostId) - const hostInfo = await hostListDB.findOneAsync({ _id: hostId }) - if (!hostInfo) { - throw new Error(`Host with ID ${ hostId } not found`) - } + const { authInfo, name, hostInfo } = await getConnectionOptions(hostId) const topology = await getMobileConnectionTopology(hostInfo) const payload = toMobileSshPayload(hostId, name, authInfo, topology) const data = encryptJsonForMobile(payload, tempKey) @@ -155,29 +152,8 @@ async function getMobileSshConnection({ request, res }) { } } -async function getMobileSftpFavorites({ params, request, res }) { - try { - const hostId = params?.hostId || request.query?.hostId - if (!hostId) { - return res.fail({ msg: 'missing hostId' }) - } - const favorites = await favoriteSftpDB.findAsync( - { hostId }, - { sort: { createTime: -1 } } - ) - return res.success({ data: favorites, msg: 'success' }) - } catch (error) { - logger.error('getMobileSftpFavorites error:', error.message) - return res.fail({ msg: error.message || 'mobile sftp favorites failed' }) - } -} + module.exports = { - getMobileSshConnection, - getMobileSftpFavorites, - getMobileConnectionTopology, - normalizePort, - normalizeMobileAuthPayload, - normalizeMobileProxy, - toMobileSshPayload + getMobileSshConnection } diff --git a/server/app/controller/sftp.js b/server/app/controller/sftp.js new file mode 100644 index 0000000..047d801 --- /dev/null +++ b/server/app/controller/sftp.js @@ -0,0 +1,24 @@ +const { HostListDB, CredentialsDB, FavoriteSftpDB } = require('../utils/db-class') + +const favoriteSftpDB = new FavoriteSftpDB().getInstance() + +async function getSftpFavorites({ params, request, res }) { + try { + const hostId = params?.hostId || request.query?.hostId + if (!hostId) { + return res.fail({ msg: 'missing hostId' }) + } + const favorites = await favoriteSftpDB.findAsync( + { hostId }, + { sort: { createTime: -1 } } + ) + return res.success({ data: favorites, msg: 'success' }) + } catch (error) { + logger.error('getSftpFavorites error:', error.message) + return res.fail({ msg: error.message || 'mobile sftp favorites failed' }) + } +} + +module.exports = { + getSftpFavorites +} diff --git a/server/app/router/routes.js b/server/app/router/routes.js index e2240a6..ec8bb83 100644 --- a/server/app/router/routes.js +++ b/server/app/router/routes.js @@ -1,4 +1,5 @@ const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey, getRdpToken } = require('../controller/ssh') +const { getSftpFavorites } = require('../controller/sftp') const { getHostList, addHost, updateHost, batchUpdateHost, removeHost, importHost, updateLastConnectTime } = require('../controller/host') const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo, getPlusDiscount, getPlusConf, updatePlusKey } = require('../controller/user') const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify') @@ -9,10 +10,10 @@ const { getOnekeyRecord, removeOnekeyRecord } = require('../controller/onekey') const { getLog, saveIpWhiteList, removeSomeLoginRecords, revokeLoginSid } = require('../controller/sessionLog') const { getAIConfig, saveAIConfig, getAIModels, getChatHistory, saveChatHistory, removeChatHistory } = require('../controller/chat') const { getProxyList, addProxy, updateProxy, removeProxy } = require('../controller/proxy') -const { getTerminalConfig, saveTerminalConfig } = require('../controller/terminal-config') -const { getServerListConfig, saveServerListConfig } = require('../controller/server-list-config') -const { getSuspendedSessions, getTerminalSessionConfig, updateTerminalSessionConfig } = require('../controller/terminal') -const { getMobileSshConnection, getMobileSftpFavorites } = require('../controller/mobile') +const { getTerminalConfig, saveTerminalConfig } = require('../controller/terminal-config') +const { getServerListConfig, saveServerListConfig } = require('../controller/server-list-config') +const { getSuspendedSessions, getTerminalSessionConfig, updateTerminalSessionConfig } = require('../controller/terminal') +const { getMobileSshConnection } = require('../controller/mobile') const ssh = [ { @@ -375,9 +376,9 @@ const serverListConfig = [ } ] -const terminal = [ - { - method: 'get', +const terminal = [ + { + method: 'get', path: '/suspended-sessions', controller: getSuspendedSessions }, @@ -390,25 +391,28 @@ const terminal = [ method: 'post', path: '/terminal-session-config', controller: updateTerminalSessionConfig - } -] - -const mobile = [ - { - method: 'post', - path: '/mobile/ssh-connection', - controller: getMobileSshConnection - }, - { - method: 'get', - path: '/mobile/sftp-favorites/:hostId', - controller: getMobileSftpFavorites - } -] - -module.exports = [].concat( - ssh, - host, + } +] + +const mobile = [ + { + method: 'post', + path: '/mobile/ssh-connection', + controller: getMobileSshConnection + } +] + +const sftp = [ + { + method: 'get', + path: '/sftp/favorites/:hostId', + controller: getSftpFavorites + } +] + +module.exports = [].concat( + ssh, + host, user, notify, group, @@ -417,9 +421,10 @@ module.exports = [].concat( onekey, log, aiConfig, - proxy, - terminalConfig, - serverListConfig, - terminal, - mobile -) + proxy, + terminalConfig, + serverListConfig, + terminal, + mobile, + sftp +) diff --git a/server/app/socket/terminal.js b/server/app/socket/terminal.js index 8dd9991..f7162fb 100644 --- a/server/app/socket/terminal.js +++ b/server/app/socket/terminal.js @@ -31,7 +31,7 @@ async function getConnectionOptions(hostId) { authInfo.authType = authType authInfo[authType] = await AESDecryptAsync(hostInfo[authType]) } - return { authInfo, name } + return { authInfo, name, hostInfo } } catch (err) { throw new Error(`解密认证信息失败: ${ err.message }`) } diff --git a/server/app/utils/mobile-crypto.js b/server/app/utils/mobile-crypto.js deleted file mode 100644 index 23c0ab9..0000000 --- a/server/app/utils/mobile-crypto.js +++ /dev/null @@ -1,44 +0,0 @@ -const crypto = require('crypto') - -function assertTempKey(key) { - if (!Buffer.isBuffer(key) || key.length !== 32) { - throw new Error('temporary key must be 32 bytes') - } -} - -function encryptJsonForMobile(payload, key) { - assertTempKey(key) - const iv = crypto.randomBytes(12) - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) - const plaintext = Buffer.from(JSON.stringify(payload), 'utf8') - const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) - const tag = cipher.getAuthTag() - - return { - alg: 'AES-256-GCM', - iv: iv.toString('base64'), - tag: tag.toString('base64'), - ciphertext: ciphertext.toString('base64') - } -} - -function decryptMobileJsonForTest(envelope, key) { - assertTempKey(key) - const decipher = crypto.createDecipheriv( - 'aes-256-gcm', - key, - Buffer.from(envelope.iv, 'base64') - ) - decipher.setAuthTag(Buffer.from(envelope.tag, 'base64')) - const plaintext = Buffer.concat([ - decipher.update(Buffer.from(envelope.ciphertext, 'base64')), - decipher.final() - ]) - return JSON.parse(plaintext.toString('utf8')) -} - -module.exports = { - assertTempKey, - encryptJsonForMobile, - decryptMobileJsonForTest -} diff --git a/server/test/test-mobile-crypto.js b/server/test/test-mobile-crypto.js deleted file mode 100644 index c8f2750..0000000 --- a/server/test/test-mobile-crypto.js +++ /dev/null @@ -1,25 +0,0 @@ -const assert = require('assert') -const { encryptJsonForMobile, decryptMobileJsonForTest, assertTempKey } = require('../app/utils/mobile-crypto') - -function testRejectsShortKey() { - assert.throws(() => assertTempKey(Buffer.alloc(16)), /temporary key must be 32 bytes/) -} - -function testEncryptsAndDecryptsJson() { - const key = Buffer.from('0123456789abcdef0123456789abcdef') - const payload = { host: '127.0.0.1', password: 'secret' } - const envelope = encryptJsonForMobile(payload, key) - - assert.strictEqual(envelope.alg, 'AES-256-GCM') - assert.ok(envelope.iv) - assert.ok(envelope.tag) - assert.ok(envelope.ciphertext) - assert.ok(!JSON.stringify(envelope).includes('secret')) - - const decoded = decryptMobileJsonForTest(envelope, key) - assert.deepStrictEqual(decoded, payload) -} - -testRejectsShortKey() -testEncryptsAndDecryptsJson() -console.log('test-mobile-crypto passed') diff --git a/server/test/test-mobile-ssh-payload.js b/server/test/test-mobile-ssh-payload.js deleted file mode 100644 index 59ecaca..0000000 --- a/server/test/test-mobile-ssh-payload.js +++ /dev/null @@ -1,205 +0,0 @@ -const assert = require('assert') -const { toMobileSshPayload } = require('../app/controller/mobile') - -function testPasswordPayload() { - const payload = toMobileSshPayload('h1', 'prod', { - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'p@ss' - }) - - assert.deepStrictEqual(payload, { - hostId: 'h1', - name: 'prod', - host: '10.0.0.2', - port: 22, - username: 'root', - authType: 'password', - password: 'p@ss', - privateKey: '', - passphrase: '', - proxyType: '', - proxy: null, - jumpHosts: [] - }) -} - -function testPrivateKeyPayload() { - const payload = toMobileSshPayload('h2', 'keyhost', { - host: '10.0.0.3', - port: 2222, - username: 'ubuntu', - authType: 'privateKey', - privateKey: 'KEY', - passphrase: 'phrase' - }) - - assert.strictEqual(payload.authType, 'privateKey') - assert.strictEqual(payload.privateKey, 'KEY') - assert.strictEqual(payload.password, '') - assert.strictEqual(payload.passphrase, 'phrase') - assert.strictEqual(payload.proxyType, '') - assert.strictEqual(payload.proxy, null) - assert.deepStrictEqual(payload.jumpHosts, []) -} - -function testRejectsUnsupportedAuth() { - assert.throws(() => toMobileSshPayload('h3', 'unsupported', { - host: '10.0.0.4', - port: 22, - username: 'root', - authType: 'keyboard' - }), /unsupported mobile ssh auth type/) -} - -function testSocks5ProxyPayload() { - const payload = toMobileSshPayload('h4', 'proxied', { - host: '10.0.0.5', - port: '2200', - username: 'deploy', - authType: 'password', - password: 'secret' - }, { - proxyType: 'proxyServer', - proxy: { - id: 'p1', - name: 'edge-proxy', - type: 'socks5', - host: '127.0.0.1', - port: '1080', - username: 'proxy-user', - password: 'proxy-pass' - } - }) - - assert.deepStrictEqual(payload.proxy, { - id: 'p1', - name: 'edge-proxy', - type: 'socks5', - host: '127.0.0.1', - port: 1080, - username: 'proxy-user', - password: 'proxy-pass' - }) - assert.strictEqual(payload.proxyType, 'proxyServer') - assert.deepStrictEqual(payload.jumpHosts, []) -} - -function testHttpProxyPayload() { - const payload = toMobileSshPayload('h4-http', 'http-proxied', { - host: '10.0.0.50', - port: 22, - username: 'deploy', - authType: 'password', - password: 'secret' - }, { - proxyType: 'proxyServer', - proxy: { - id: 'p-http', - name: 'http-proxy', - type: 'http', - host: '127.0.0.1', - port: '8080', - username: 'proxy-user', - password: 'proxy-pass' - } - }) - - assert.deepStrictEqual(payload.proxy, { - id: 'p-http', - name: 'http-proxy', - type: 'http', - host: '127.0.0.1', - port: 8080, - username: 'proxy-user', - password: 'proxy-pass' - }) - assert.strictEqual(payload.proxyType, 'proxyServer') - assert.deepStrictEqual(payload.jumpHosts, []) -} - -function testJumpHostsPayload() { - const payload = toMobileSshPayload('h5', 'target', { - host: '10.0.0.6', - port: 22, - username: 'app', - authType: 'privateKey', - privateKey: 'TARGET_KEY', - password: 'ignored', - passphrase: '' - }, { - proxyType: 'jumpHosts', - jumpHosts: [ - { - hostId: 'j1', - name: 'bastion', - host: '10.0.0.7', - port: '2222', - username: 'jump', - authType: 'password', - password: 'jump-pass', - privateKey: 'ignored' - } - ] - }) - - assert.strictEqual(payload.proxyType, 'jumpHosts') - assert.strictEqual(payload.proxy, null) - assert.deepStrictEqual(payload.jumpHosts, [ - { - hostId: 'j1', - name: 'bastion', - host: '10.0.0.7', - port: 2222, - username: 'jump', - authType: 'password', - password: 'jump-pass', - privateKey: '', - passphrase: '' - } - ]) -} - -function testRejectsUnsupportedProxyType() { - assert.throws(() => toMobileSshPayload('h6', 'bad-proxy', { - host: '10.0.0.8', - port: 22, - username: 'root', - authType: 'password', - password: 'secret' - }, { - proxyType: 'proxyServer', - proxy: { - id: 'p2', - name: 'https-proxy', - type: 'https', - host: '127.0.0.1', - port: 8080 - } - }), /unsupported mobile proxy type: https/) -} - -function testRejectsEmptyJumpHostChain() { - assert.throws(() => toMobileSshPayload('h7', 'empty-jumps', { - host: '10.0.0.9', - port: 22, - username: 'root', - authType: 'password', - password: 'secret' - }, { - proxyType: 'jumpHosts', - jumpHosts: [] - }), /mobile jump host chain is empty/) -} - -testPasswordPayload() -testPrivateKeyPayload() -testRejectsUnsupportedAuth() -testSocks5ProxyPayload() -testHttpProxyPayload() -testJumpHostsPayload() -testRejectsUnsupportedProxyType() -testRejectsEmptyJumpHostChain() -console.log('test-mobile-ssh-payload passed')