From 9f04c8adbb2a43629ba12cc9292d8e04263b2d98 Mon Sep 17 00:00:00 2001 From: chaos-zhu Date: Tue, 20 Aug 2024 10:44:16 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E6=94=AF=E6=8C=81IP=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E8=AE=BF=E9=97=AE=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/.env.template | 3 ++ server/app/middlewares/auth.js | 6 +-- server/app/middlewares/index.js | 3 +- server/app/middlewares/ipFilter.js | 16 ++++++ server/app/middlewares/static.js | 2 +- server/app/server.js | 2 - server/app/socket/clients.js | 10 +++- server/app/socket/host-status.js | 74 ---------------------------- server/app/socket/onekey.js | 10 +++- server/app/socket/sftp.js | 30 ++++++----- server/app/socket/terminal.js | 10 +++- server/app/template/ipForbidden.html | 43 ++++++++++++++++ server/app/utils/tools.js | 26 ++++++---- 13 files changed, 127 insertions(+), 108 deletions(-) create mode 100644 server/app/middlewares/ipFilter.js delete mode 100644 server/app/socket/host-status.js create mode 100644 server/app/template/ipForbidden.html diff --git a/server/.env.template b/server/.env.template index 03c8c62..2aa3f6b 100644 --- a/server/.env.template +++ b/server/.env.template @@ -1,2 +1,5 @@ # 启动debug日志 0:关闭 1:开启 DEBUG=1 + +# 访问IP限制 +allowedIPs=['127.0.0.1'] \ No newline at end of file diff --git a/server/app/middlewares/auth.js b/server/app/middlewares/auth.js index b12f0c8..6308c02 100644 --- a/server/app/middlewares/auth.js +++ b/server/app/middlewares/auth.js @@ -10,11 +10,11 @@ consola.info('路由白名单:', whitePath) const useAuth = async ({ request, res }, next) => { const { path, headers: { token } } = request consola.info('verify path: ', path) - if(whitePath.includes(path)) return next() - if(!token) return res.fail({ msg: '未登录', status: 403 }) + if (whitePath.includes(path)) return next() + if (!token) return res.fail({ msg: '未登录', status: 403 }) // 验证token const { code, msg } = await verifyAuthSync(token, request.ip) - switch(code) { + switch (code) { case 1: return await next() case -1: diff --git a/server/app/middlewares/index.js b/server/app/middlewares/index.js index f7932cd..0e3ff7d 100644 --- a/server/app/middlewares/index.js +++ b/server/app/middlewares/index.js @@ -1,3 +1,4 @@ +const ipFilter = require('./ipFilter') // IP过滤 const responseHandler = require('./response') // 统一返回格式, 错误捕获 const useAuth = require('./auth') // 鉴权 // const useCors = require('./cors') // 处理跨域[暂时禁止] @@ -8,8 +9,8 @@ const useStatic = require('./static') // 静态目录 const compress = require('./compress') // br/gzip压缩 const history = require('./history') // vue-router的history模式 -// 注意注册顺序 module.exports = [ + ipFilter, compress, history, useStatic, // staic先注册,不然会被jwt拦截 diff --git a/server/app/middlewares/ipFilter.js b/server/app/middlewares/ipFilter.js new file mode 100644 index 0000000..d0f67ed --- /dev/null +++ b/server/app/middlewares/ipFilter.js @@ -0,0 +1,16 @@ +// 白名单IP +const fs = require('fs') +const path = require('path') +const { isAllowedIp } = require('../utils/tools') + +const htmlPath = path.join(__dirname, '../template/ipForbidden.html') +const ipForbiddenHtml = fs.readFileSync(htmlPath, 'utf8') + +const ipFilter = async (ctx, next) => { + // console.log('requestIP:', ctx.request.ip) + if (isAllowedIp(ctx.request.ip)) return await next() + ctx.status = 403 + ctx.body = ipForbiddenHtml +} + +module.exports = ipFilter diff --git a/server/app/middlewares/static.js b/server/app/middlewares/static.js index 11542fc..590fa89 100644 --- a/server/app/middlewares/static.js +++ b/server/app/middlewares/static.js @@ -5,7 +5,7 @@ const useStatic = koaStatic(staticDir, { maxage: 1000 * 60 * 60 * 24 * 30, gzip: true, setHeaders: (res, path) => { - if(path && path.endsWith('.html')) { + if (path && path.endsWith('.html')) { res.setHeader('Cache-Control', 'max-age=0') } } diff --git a/server/app/server.js b/server/app/server.js index 2789e90..c462e09 100644 --- a/server/app/server.js +++ b/server/app/server.js @@ -5,7 +5,6 @@ const { httpPort } = require('./config') const middlewares = require('./middlewares') const wsTerminal = require('./socket/terminal') const wsSftp = require('./socket/sftp') -// const wsHostStatus = require('./socket/host-status') const wsClientInfo = require('./socket/clients') const wsOnekey = require('./socket/onekey') const { throwError } = require('./utils/tools') @@ -25,7 +24,6 @@ function serverHandler(app, server) { app.proxy = true // 用于nginx反代时获取真实客户端ip wsTerminal(server) // 终端 wsSftp(server) // sftp - // wsHostStatus(server) // 终端侧边栏host信息(单个host) wsOnekey(server) // 一键指令 wsClientInfo(server) // 客户端信息 app.context.throwError = throwError // 常用方法挂载全局ctx上 diff --git a/server/app/socket/clients.js b/server/app/socket/clients.js index 643d226..dbbdecc 100644 --- a/server/app/socket/clients.js +++ b/server/app/socket/clients.js @@ -3,6 +3,7 @@ const { io: ClientIO } = require('socket.io-client') const { readHostList } = require('../utils/storage') const { clientPort } = require('../config') const { verifyAuthSync } = require('../utils/verify-auth') +const { isAllowedIp } = require('../utils/tools') let clientSockets = [] let clientsData = {} @@ -66,9 +67,14 @@ module.exports = (httpServer) => { serverIo.on('connection', (socket) => { // 前者兼容nginx反代, 后者兼容nodejs自身服务 - let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + if (!isAllowedIp(requestIP)) { + socket.emit('ip_forbidden', 'IP地址不在白名单中') + socket.disconnect() + return + } socket.on('init_clients_data', async ({ token }) => { - const { code, msg } = await verifyAuthSync(token, clientIp) + const { code, msg } = await verifyAuthSync(token, requestIP) if (code !== 1) { socket.emit('token_verify_fail', msg || '鉴权失败') socket.disconnect() diff --git a/server/app/socket/host-status.js b/server/app/socket/host-status.js deleted file mode 100644 index 6122b69..0000000 --- a/server/app/socket/host-status.js +++ /dev/null @@ -1,74 +0,0 @@ -const { Server: ServerIO } = require('socket.io') -const { io: ClientIO } = require('socket.io-client') -const { clientPort } = require('../config') -const { verifyAuthSync } = require('../utils/verify-auth') - -let hostSockets = {} - -function getHostInfo(serverSocket, host) { - let hostSocket = ClientIO(`http://${ host }:${ clientPort }`, { - path: '/client/os-info', - forceNew: false, - timeout: 5000, - reconnectionDelay: 3000, - reconnectionAttempts: 3 - }) - // 将与客户端连接的socket实例保存起来,web端断开时关闭与客户端的连接 - hostSockets[serverSocket.id] = hostSocket - - hostSocket - .on('connect', () => { - consola.success('host-status-socket连接成功:', host) - hostSocket.on('client_data', (data) => { - serverSocket.emit('host_data', data) - }) - hostSocket.on('client_error', () => { - serverSocket.emit('host_data', null) - }) - }) - .on('connect_error', (error) => { - consola.error('host-status-socket连接[失败]:', host, error.message) - serverSocket.emit('host_data', null) - }) - .on('disconnect', () => { - consola.info('host-status-socket连接[断开]:', host) - serverSocket.emit('host_data', null) - }) -} - -module.exports = (httpServer) => { - const serverIo = new ServerIO(httpServer, { - path: '/host-status', - cors: { - origin: '*' // 需配置跨域 - } - }) - - serverIo.on('connection', (serverSocket) => { - // 前者兼容nginx反代, 后者兼容nodejs自身服务 - let clientIp = serverSocket.handshake.headers['x-forwarded-for'] || serverSocket.handshake.address - serverSocket.on('init_host_data', async ({ token, host }) => { - // 校验登录态 - const { code, msg } = await verifyAuthSync(token, clientIp) - if(code !== 1) { - serverSocket.emit('token_verify_fail', msg || '鉴权失败') - serverSocket.disconnect() - return - } - - // 获取客户端数据 - getHostInfo(serverSocket, host) - - consola.info('host-status-socket连接socketId: ', serverSocket.id, 'host-status-socket已连接数: ', Object.keys(hostSockets).length) - - // 关闭连接 - serverSocket.on('disconnect', () => { - // 当web端与服务端断开连接时, 服务端与每个客户端的socket也应该断开连接 - let socket = hostSockets[serverSocket.id] - socket.close && socket.close() - delete hostSockets[serverSocket.id] - consola.info('host-status-socket剩余连接数: ', Object.keys(hostSockets).length) - }) - }) - }) -} diff --git a/server/app/socket/onekey.js b/server/app/socket/onekey.js index 82d8e5b..94b36ee 100644 --- a/server/app/socket/onekey.js +++ b/server/app/socket/onekey.js @@ -5,6 +5,7 @@ const { readSSHRecord, readHostList, writeOneKeyRecord } = require('../utils/sto const { verifyAuthSync } = require('../utils/verify-auth') const { shellThrottle } = require('../utils/tools') const { AESDecryptSync } = require('../utils/encrypt') +const { isAllowedIp } = require('../utils/tools') const execStatusEnum = { connecting: '连接中', @@ -90,7 +91,12 @@ module.exports = (httpServer) => { }) serverIo.on('connection', (socket) => { // 前者兼容nginx反代, 后者兼容nodejs自身服务 - let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + if (!isAllowedIp(requestIP)) { + socket.emit('ip_forbidden', 'IP地址不在白名单中') + socket.disconnect() + return + } consola.success('onekey-terminal websocket 已连接') if (isExecuting) { socket.emit('create_fail', '正在执行中, 请稍后再试') @@ -99,7 +105,7 @@ module.exports = (httpServer) => { } isExecuting = true socket.on('create', async ({ hosts, token, command, timeout }) => { - const { code } = await verifyAuthSync(token, clientIp) + const { code } = await verifyAuthSync(token, requestIP) if (code !== 1) { socket.emit('token_verify_fail') socket.disconnect() diff --git a/server/app/socket/sftp.js b/server/app/socket/sftp.js index 8c94ac4..1165452 100644 --- a/server/app/socket/sftp.js +++ b/server/app/socket/sftp.js @@ -7,6 +7,7 @@ const { sftpCacheDir } = require('../config') const { verifyAuthSync } = require('../utils/verify-auth') const { AESDecryptSync } = require('../utils/encrypt') const { readSSHRecord, readHostList } = require('../utils/storage') +const { isAllowedIp } = require('../utils/tools') // 读取切片 const pipeStream = (path, writeStream) => { @@ -23,7 +24,7 @@ const pipeStream = (path, writeStream) => { function listenInput(sftpClient, socket) { socket.on('open_dir', async (path, tips = true) => { const exists = await sftpClient.exists(path) - if(!exists) return socket.emit('not_exists_dir', tips ? '目录不存在或当前不可访问' : '') + if (!exists) return socket.emit('not_exists_dir', tips ? '目录不存在或当前不可访问' : '') try { let dirLs = await sftpClient.list(path) socket.emit('dir_ls', dirLs, path) @@ -34,7 +35,7 @@ function listenInput(sftpClient, socket) { }) socket.on('rm_dir', async (path) => { const exists = await sftpClient.exists(path) - if(!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问') + if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问') try { let res = await sftpClient.rmdir(path, true) // 递归删除 socket.emit('rm_success', res) @@ -45,7 +46,7 @@ function listenInput(sftpClient, socket) { }) socket.on('rm_file', async (path) => { const exists = await sftpClient.exists(path) - if(!exists) return socket.emit('not_exists_dir', '文件不存在或当前不可访问') + if (!exists) return socket.emit('not_exists_dir', '文件不存在或当前不可访问') try { let res = await sftpClient.delete(path) socket.emit('rm_success', res) @@ -65,13 +66,13 @@ function listenInput(sftpClient, socket) { socket.on('down_file', async ({ path, name, size, target = 'down' }) => { // target: down or preview const exists = await sftpClient.exists(path) - if(!exists) return socket.emit('not_exists_dir', '文件不存在或当前不可访问') + if (!exists) return socket.emit('not_exists_dir', '文件不存在或当前不可访问') try { const localPath = rawPath.join(sftpCacheDir, name) let timer = null let res = await sftpClient.fastGet(path, localPath, { step: step => { - if(timer) return + if (timer) return timer = setTimeout(() => { const percent = Math.ceil((step / size) * 100) // 下载进度为服务器下载到服务端的进度,前端无需*2 console.log(`从服务器下载进度:${ percent }%`) @@ -83,7 +84,7 @@ function listenInput(sftpClient, socket) { consola.success('sftp下载成功: ', res) let buffer = fs.readFileSync(localPath) let data = { buffer, name } - switch(target) { + switch (target) { case 'down': socket.emit('down_file_success', data) break @@ -102,7 +103,7 @@ function listenInput(sftpClient, socket) { socket.on('up_file', async ({ targetPath, fullPath, name, file }) => { // console.log({ targetPath, fullPath, name, file }) const exists = await sftpClient.exists(targetPath) - if(!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问') + if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问') try { const localPath = rawPath.join(sftpCacheDir, name) fs.writeFileSync(localPath, file) @@ -137,7 +138,7 @@ function listenInput(sftpClient, socket) { socket.on('create_cache_dir', async ({ targetDirPath, name }) => { // console.log({ targetDirPath, name }) const exists = await sftpClient.exists(targetDirPath) - if(!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问') + if (!exists) return socket.emit('not_exists_dir', '目录不存在或当前不可访问') md5List = [] const localPath = rawPath.join(sftpCacheDir, name) fs.emptyDirSync(localPath) // 不存在会创建,存在则清空 @@ -178,7 +179,7 @@ function listenInput(sftpClient, socket) { let timer = null let res = await sftpClient.fastPut(resultFilePath, targetFilePath, { step: step => { - if(timer) return + if (timer) return timer = setTimeout(() => { const percent = Math.ceil((step / size) * 100) console.log(`上传服务器进度:${ percent }%`) @@ -210,13 +211,18 @@ module.exports = (httpServer) => { }) serverIo.on('connection', (socket) => { // 前者兼容nginx反代, 后者兼容nodejs自身服务 - let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + if (!isAllowedIp(requestIP)) { + socket.emit('ip_forbidden', 'IP地址不在白名单中') + socket.disconnect() + return + } let sftpClient = new SFTPClient() consola.success('terminal websocket 已连接') socket.on('create', async ({ host: ip, token }) => { - const { code } = await verifyAuthSync(token, clientIp) - if(code !== 1) { + const { code } = await verifyAuthSync(token, requestIP) + if (code !== 1) { socket.emit('token_verify_fail') socket.disconnect() return diff --git a/server/app/socket/terminal.js b/server/app/socket/terminal.js index 4c6fd91..997716e 100644 --- a/server/app/socket/terminal.js +++ b/server/app/socket/terminal.js @@ -4,6 +4,7 @@ const { verifyAuthSync } = require('../utils/verify-auth') const { AESDecryptSync } = require('../utils/encrypt') const { readSSHRecord, readHostList } = require('../utils/storage') const { asyncSendNotice } = require('../utils/notify') +const { isAllowedIp } = require('../utils/tools') function createInteractiveShell(socket, sshClient) { return new Promise((resolve) => { @@ -113,11 +114,16 @@ module.exports = (httpServer) => { }) serverIo.on('connection', (socket) => { // 前者兼容nginx反代, 后者兼容nodejs自身服务 - let clientIp = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + let requestIP = socket.handshake.headers['x-forwarded-for'] || socket.handshake.address + if (!isAllowedIp(requestIP)) { + socket.emit('ip_forbidden', 'IP地址不在白名单中') + socket.disconnect() + return + } consola.success('terminal websocket 已连接') let sshClient = null socket.on('create', async ({ host: ip, token }) => { - const { code } = await verifyAuthSync(token, clientIp) + const { code } = await verifyAuthSync(token, requestIP) if (code !== 1) { socket.emit('token_verify_fail') socket.disconnect() diff --git a/server/app/template/ipForbidden.html b/server/app/template/ipForbidden.html new file mode 100644 index 0000000..cddbf5d --- /dev/null +++ b/server/app/template/ipForbidden.html @@ -0,0 +1,43 @@ + + + + + + + 403 禁止访问 + + + + + +
+

403 禁止访问

+

抱歉,您没有权限访问此页面。

+
+ + + \ No newline at end of file diff --git a/server/app/utils/tools.js b/server/app/utils/tools.js index a817ad8..7ec96de 100644 --- a/server/app/utils/tools.js +++ b/server/app/utils/tools.js @@ -36,37 +36,37 @@ const getNetIPInfo = async (searchIp = '') => { let [ipApi, ipwho, ipdata, ipinfo, ipgeolocation, ipApi01, ip138] = result let searchResult = [] - if(ipApi.status === 'fulfilled') { + if (ipApi.status === 'fulfilled') { let { query: ip, country, regionName, city } = ipApi.value?.data || {} searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date }) } - if(ipwho.status === 'fulfilled') { + if (ipwho.status === 'fulfilled') { let { ip, country, region: regionName, city } = ipwho.value?.data || {} searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date }) } - if(ipdata.status === 'fulfilled') { + if (ipdata.status === 'fulfilled') { let { ip, country_name: country, region: regionName, city } = ipdata.value?.data || {} searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date }) } - if(ipinfo.status === 'fulfilled') { + if (ipinfo.status === 'fulfilled') { let { ip, country, region: regionName, city } = ipinfo.value?.data || {} searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date }) } - if(ipgeolocation.status === 'fulfilled') { + if (ipgeolocation.status === 'fulfilled') { let { ip, country_name: country, state_prov: regionName, city } = ipgeolocation.value?.data || {} searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date }) } - if(ipApi01.status === 'fulfilled') { + if (ipApi01.status === 'fulfilled') { let { ip, country_name: country, region: regionName, city } = ipApi01.value?.data || {} searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date }) } - if(ip138.status === 'fulfilled') { + if (ip138.status === 'fulfilled') { let [res] = ip138.value?.data?.data || [] let { origip: ip, location: country, city = '', regionName = '' } = res || {} searchResult.push({ ip, country, city: `${ regionName } ${ city }`, date }) @@ -175,7 +175,7 @@ const getUTCDate = (num = 8) => { } const formatTimestamp = (timestamp = Date.now(), format = 'time') => { - if(typeof(timestamp) !== 'number') return '--' + if (typeof(timestamp) !== 'number') return '--' let date = new Date(timestamp) let padZero = (num) => String(num).padStart(2, '0') let year = date.getFullYear() @@ -231,6 +231,13 @@ const isProd = () => { return EXEC_ENV === 'production' } +let allowedIPs = process.env.ALLOWED_IPS ? process.env.ALLOWED_IPS.split(',') : '' +if (allowedIPs) consola.warn('allowedIPs:', allowedIPs) +const isAllowedIp = (requestIP) => { + if (allowedIPs.length === 0) return true + return allowedIPs.some(item => item.includes(requestIP)) +} + module.exports = { getNetIPInfo, throwError, @@ -240,5 +247,6 @@ module.exports = { formatTimestamp, resolvePath, shellThrottle, - isProd + isProd, + isAllowedIp } \ No newline at end of file