宝塔面板批量封IP---node.js增量式封锁脚本(每日自动封代理池IP)

4 篇文章 0 订阅
3 篇文章 0 订阅

一句话需求

  • 现在是七月, 从三月开始我的一个网站一直受到几百个IP的流量攻击, 具体表现就是日志里面出现大量访问一个固定url网址的不带reffer的手机端的国内IP段的大量请求. 每秒请求超过50次.
  • 一开始用宝塔面板免费WAF nginx防火墙, 能防住, 但是效果不好, 依然会有大量额外的图片等资源请求.
  • 事件的经过在没查明IP之前我是不想封的, 因为有些站群的操作手法就是克隆我的网站来引流到他们自己的网站, 这种手法会造成大量访问我IP的请求都是来自真实用户的手机. 然后根绝我长期采样观察发现这些虽然是真人IP但是应该是来自他们手机里面的后门, 所以流量对我没有意义, 并且通过User-Agent分析是代理池的可能性很大, 鬼知道这些代理池以后会干什么啊, 换个User-Agent也是简简单单啊, 所以直接封杀掉IP好了.
  • 定性之后就要封IP了. nginx那个防火墙不好用, 实际上请求还是请求了nginx的只是nginx返回了错误信息直接挡住了.所以nginx依然是有负载的.总觉得不爽.
  • 我们要用linux自带的防火墙来直接封IP才能有效抵御住DDOS. 我使用的是 centos 7.6 系统自带防火墙是 firewalld , 我禁用了iptable
  • 本文实现了 “单独为宝塔系统防火墙3.0生成批量导入屏蔽配置文件” 和 “直接为linux的firewall生成防火墙规则并自动每日导入”
  • 可视化管理是宝塔面板的系统防火墙3.0在这里插入图片描述

在这里插入图片描述
这是利用centos最新的系统级firewalld来封锁IP的高级防火墙, 比iptable更好.

  • 从图片上的导入规则我们可以批量导入要封锁的IP地址
  • 导入格式是{"id": 1, "types": "drop", "address": "171.8.172.145", "brief": "", "addtime": "2021-07-28 17:26:50"}

需求落地:

  • 分析nginx的web日志
  • 提取要封锁的IP
  • 生成JSON规则列表, 并保留历史记录, 下一次分析日志就只需要添加新规则
  • 一次性导入即可

项目命名 digIP.js

文件夹结构

在这里插入图片描述
源码:

./digIP.js 主程

const fs = require('fs').promises
const path = require('path')
const writeLog = require('./utils/write-log.js')
const formatDate = require('./utils/datetime-format.js').formatDate
const runCMD = require('./utils/exec.js').runScript
const myEnv = require('./utils/my-env.js')
/*
分析网站日志 main 组
提取非法IP列表
输出指定格式JSON
**********************使用放方法:
配置config里面的weblog路径地址, 必须是main路径或者IP在第一位(日志分隔符是空格), main配置格式见后
首次执行, 执行即可在当前目录下生成一个JSON文件, 复制这个文件内容到宝塔的系统防火墙导入IP界面即可(导入之前先删除老的JSON, 700个IP耗时半小时, 这个导入时间很长, 是宝塔的问题)
二次执行, 会生成全新JSON, 包含老的(这里要优化下, 提供只生成新IP的策略JSON)
之后对新weblog分析(含老日志或者全新日志都可以), 会自动对比老配置文件和新weblog, 自动去重已添加的IP并分配新的序列号
我的main log日志样本
171.8.172.9 - - [28/Jul/2021:19:24:29 +0800] requesthost:"m.xxx.com"; "GET /tag/XX/return%20false8382610997276947256873 HTTP/1.1" requesttime:"0.000"; 444 0 "-" - - "Mozilla/5.0(Linux;U;Android 5.1.1;zh-CN;OPPO A33 Build/LMY47V) AppleWebKit/537.36(KHTML,like Gecko) Version/4.0 Chrome/40.0.2214.89 UCBrowser/11.7.0.953 Mobile Safari/537.36" "-"
*/
const config = {
  newIpNewFile: true, // true 单独输出一份新IP输出为独立json文件; false 不单独输出一份
  logFile: String.raw`F:\my_download\你的网站nginx访问日志文件(单行数据里面是空格分隔,其中IP是第一个数据).log`,
  outputFile: path.resolve(__dirname, 'output-policy.json'),
    policy: {
    keyword: String.raw`(return(%20| )false\d+|OPPO A33 Build)`,
    pattern:
      /((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)) - .*(return(%20| )false\d+|OPPO A33 Build|Android 7.0; FRD-AL00 Build)/g
  },
  executeFinalBanIPBash: false // 是否在程序结束之前运行 BanIP 的bash
}
const getIPList = async () => {
  const ctx = await fs.readFile(config.logFile, { encoding: 'utf-8' })
  let list = ctx.split('\n')
  console.log(`总计 ${list.length} 条记录`)
  list = ctx.matchAll(config.policy.pattern)
  const tmp = []
  for (const item of list) {
    tmp.push(item[1])
  }
  list = tmp
  console.log(`找到 ${list.length} 条非法记录`)
  list = list.map(item => item.split(' ')[0])
  list = [...new Set(list)]
  list = list.sort()
  console.log(`本次攻击IP总计: ${list.length}`)
  return list
}

// 生成封锁模板
// {"id": 1, "types": "drop", "address": "171.8.172.145", "brief": "", "addtime": "2021-07-28 17:26:50"}
const outputJSON = async (ipList, JsonInfo = 1, type = 'bt') =>
  ipList.map((item, index, arr) => ({
    id:
      index +
      (typeof JsonInfo === 'number'
        ? JsonInfo
        : JsonInfo.length > 0
        ? JsonInfo.slice(-1)[0].id + 1
        : 1),
    types: 'drop',
    address: item.trim(),
    brief: '',
    addtime: formatDate(Date.now(), 'yyyy-MM-dd hh:mm:ss')
  }))

// 对比本地list 检测新的
const getNewJsonDeltaBundle = async ipList => {
  const oldJSON = JSON.parse(
    await fs.readFile(config.outputFile, { encoding: 'utf-8' })
  )
  if (oldJSON.length > 0) {
    const oldIPs = oldJSON.map(item => item.address)
    return [ipList.filter(item => !oldIPs.includes(item)), oldJSON]
  } else return [ipList, oldJSON]
}

;(async () => {
  const timeElapseFlag = '🎈All Done'
  console.time(timeElapseFlag)
  const ipList = await getIPList()
  let jsonList = await outputJSON(ipList)

  try {
    await fs.access(config.outputFile)
  } catch (err) {
    await writeLog(config.outputFile, JSON.stringify([]), false)
  }

  const [newIPList, oldJSON] = await getNewJsonDeltaBundle(ipList)
  if (newIPList.length) {
    console.log('写入新IP', newIPList)
    if (config.newIpNewFile) {
      jsonList = [...(await outputJSON(newIPList, oldJSON))]
      await writeLog(
        `${config.outputFile.slice(
          0,
          -5
        )}_${Date.now()}${config.outputFile.slice(-5)}`,
        JSON.stringify(jsonList),
        false
      )
    }

    jsonList = [...oldJSON, ...(await outputJSON(newIPList, oldJSON))]
    await writeLog(config.outputFile, JSON.stringify(jsonList), false)
  } else {
    console.log('没有找到需要添加的新IP')
  }
  
  console.timeEnd(timeElapseFlag)
})()


时间格式化模块 ./utils/datetime-forst.js

/**
* ./utils/datetime-format.js
 * 计算指定时间到当前时间的时间间隔
 * @param {*} time 指定时间
 * @return 时间间隔或年月日时分
 *
 * example:
 * time2MinuteOrHour('2020-04-25T13:42:00.000Z')
 */
const time2MinuteOrHour = time => {
  const now = new Date()
  const pass = new Date(time)
  const result = now - pass
  // 分钟差小于60分钟
  if (parseInt(parseInt(result / 1000, 0) / 60, 0) < 60) {
    return `${Math.ceil(result / 1000 / 60)}分钟前`
  }
  // 小时差小于16小时
  if (parseInt(parseInt(parseInt(result / 1000, 0) / 60, 0) / 60, 0) < 16) {
    return `${Math.ceil(result / 1000 / 60 / 60)}小时前`
  }
  // 超过16个小时展示 年月日时分
  return time.replace(/T/, ' ').replace(/Z/, '').substring(0, 16)
}

/**
 * 时间转换为年月日时分
 * @param {*} originTime 原始时间
 * @return 年月日 时分
 *
 * example:
 * time2DateAndHM('2020-04-25T11:54:17+08:00')
 */
const time2DateAndHM = originTime => {
  const time = originTime.replace(/T/, ' ').replace(/Z/, '')
  return `${time.substring(0, 10)} ${time.substring(11, 16)}`
}

/**
 * 有效期(时间戳减去当前时间戳再转换为天)
 * @param {*} timestamp 时间戳
 * @return 天
 *
 * example:
 * timestamp2day(1589785128)
 */
const timestamp2day = timestamp => {
  const interval = timestamp - Math.round(new Date() / 1000)
  return parseInt(interval / (60 * 60 * 24), 0)
}

// yyyy-MM-dd hh:mm:ss.SSS 所有支持的类型
function pad(str, length = 2) {
  str += ''
  while (str.length < length) {
    str = '0' + str
  }
  return str.slice(-length)
}

const parser = {
  yyyy: dateObj => {
    return pad(dateObj.year, 4)
  },
  yy: dateObj => {
    return pad(dateObj.year)
  },
  MM: dateObj => {
    return pad(dateObj.month)
  },
  M: dateObj => {
    return dateObj.month
  },
  dd: dateObj => {
    return pad(dateObj.day)
  },
  d: dateObj => {
    return dateObj.day
  },
  hh: dateObj => {
    return pad(dateObj.hour)
  },
  h: dateObj => {
    return dateObj.hour
  },
  mm: dateObj => {
    return pad(dateObj.minute)
  },
  m: dateObj => {
    return dateObj.minute
  },
  ss: dateObj => {
    return pad(dateObj.second)
  },
  s: dateObj => {
    return dateObj.second
  },
  SSS: dateObj => {
    return pad(dateObj.millisecond, 3)
  },
  S: dateObj => {
    return dateObj.millisecond
  }
}

// 这都n年了iOS依然不认识2020-12-12,需要转换为2020/12/12
function getDate(time) {
  if (time instanceof Date) {
    return time
  }
  switch (typeof time) {
    case 'string':
      return new Date(time.replace(/-/g, '/'))
    default:
      return new Date(time)
  }
}

function formatDate(date, format = 'yyyy/MM/dd hh:mm:ss') {
  if (!date && date !== 0) {
    return '-'
  }
  date = getDate(date)
  const dateObj = {
    year: date.getFullYear(),
    month: date.getMonth() + 1,
    day: date.getDate(),
    hour: date.getHours(),
    minute: date.getMinutes(),
    second: date.getSeconds(),
    millisecond: date.getMilliseconds()
  }
  const tokenRegExp = /yyyy|yy|MM|M|dd|d|hh|h|mm|m|ss|s|SSS|SS|S/
  let flag = true
  let result = format
  while (flag) {
    flag = false
    result = result.replace(tokenRegExp, function (matched) {
      flag = true
      return parser[matched](dateObj)
    })
  }
  return result
}

function friendlyDate(
  time,
  {
    locale = 'zh',
    threshold = [60000, 3600000],
    format = 'yyyy/MM/dd hh:mm:ss'
  }
) {
  if (!time && time !== 0) {
    return '-'
  }
  const localeText = {
    zh: {
      year: '年',
      month: '月',
      day: '天',
      hour: '小时',
      minute: '分钟',
      second: '秒',
      ago: '前',
      later: '后',
      justNow: '刚刚',
      soon: '马上',
      template: '{num}{unit}{suffix}'
    },
    en: {
      year: 'year',
      month: 'month',
      day: 'day',
      hour: 'hour',
      minute: 'minute',
      second: 'second',
      ago: 'ago',
      later: 'later',
      justNow: 'just now',
      soon: 'soon',
      template: '{num} {unit} {suffix}'
    }
  }
  const text = localeText[locale] || localeText.zh
  const date = getDate(time)
  let ms = date.getTime() - Date.now()
  const absMs = Math.abs(ms)
  if (absMs < threshold[0]) {
    return ms < 0 ? text.justNow : text.soon
  }
  if (absMs >= threshold[1]) {
    return formatDate(date, format)
  }
  let num
  let unit
  let suffix = text.later
  if (ms < 0) {
    suffix = text.ago
    ms = -ms
  }
  const seconds = Math.floor(ms / 1000)
  const minutes = Math.floor(seconds / 60)
  const hours = Math.floor(minutes / 60)
  const days = Math.floor(hours / 24)
  const months = Math.floor(days / 30)
  const years = Math.floor(months / 12)
  switch (true) {
    case years > 0:
      num = years
      unit = text.year
      break
    case months > 0:
      num = months
      unit = text.month
      break
    case days > 0:
      num = days
      unit = text.day
      break
    case hours > 0:
      num = hours
      unit = text.hour
      break
    case minutes > 0:
      num = minutes
      unit = text.minute
      break
    default:
      num = seconds
      unit = text.second
      break
  }

  if (locale === 'en') {
    if (num === 1) {
      num = 'a'
    } else {
      unit += 's'
    }
  }

  return text.template
    .replace(/{\s*num\s*}/g, num + '')
    .replace(/{\s*unit\s*}/g, unit)
    .replace(/{\s*suffix\s*}/g, suffix)
}

module.exports = {
  formatDate
}

写文件模块
./utils/write-file.js

const fs = require('fs').promises
const path = require('path')

/**
 * Write file from path
 * @param {String} _path Relative path
 * @param {[String]} file File data
 */
const writeFile = async (_path, file, flag = 'w+') => {
  const relativePath = path.resolve(__dirname, _path)
  const { dir } = path.parse(relativePath)
  await fs.mkdir(path.resolve(__dirname, dir), { recursive: true })
  // console.log('file write to ', relativePath)
  return fs.writeFile(relativePath, file, { flag: 'w+' })
}

module.exports = writeFile

写日志模块
./utils/wirte-log.js

const fs = require('fs').promises
const writeLog = async (file, str, isAppend = true) => {
  str && isAppend
    ? await fs.appendFile(file, str + '\n', 'utf-8')
    : await fs.writeFile(file, str + '\n', 'utf-8')
}

module.exports = writeLog

执行bash子进程
./utils/exec.js

const util = require('util')
const exec = util.promisify(require('child_process').exec)

const runScript = async (command, timeout = 10000) => {
  console.log('>> ' + command)
  const { stdout, stderr } = await exec(command, { timeout: timeout })
  if (stderr) {
    console.error(stderr)
    throw new Error(stderr)
  }
  return stdout
}

module.exports = {
  runScript
}

/utils/my-env.js

// 因为我有多个电脑, 这个文件主要是自动判定生产环境和开发环境的不同的weblog日志的路径, 让代码在不同电脑上直接运行. 所以改成你们自己的日志文件路径就可以了. 这里我就不放出来了

代码执行

node digIP.js

  • 初次运行
    在这里插入图片描述
  • 二次运行(无新IP)
    在这里插入图片描述
  • 三次运行, 加入增量IP
  • 在这里插入图片描述

附上nginx的main节点日志格式

log_format main '$remote_addr - $remote_user [$time_local] requesthost:"$http_host"; "$request" requesttime:"$request_time"; $status $body_bytes_sent "$http_referer" - $request_body "$http_user_agent" "$http_x_forwarded_for"';

后记

生成的JSON文件用宝塔里面的系统防火墙IP屏蔽导入配置即可
在这里插入图片描述
流量刷一下就下去了
清空web日志
第二天再次观察还有几个漏网之鱼
再次执行程序得到新的配置文件
宝塔导入之
妥妥封死了

[ 2021年7月31日更新 ] 抛弃了宝塔系统防火墙, 直接注入linux系统级firewalld规则, 速度提升千倍(2秒封1000个IP)

  • 增加服务器全自动每日凌晨定时封自动封IP功能

  • 其实就是在上述代码末尾的timeEnd打印之前插入一段代码即可, 老代码可以保留不影响
    在这里插入图片描述

  • 本质上就是批量一次性添加上万条IP到bloclist规则,所以, 要每日生效之前需要进行blocklist规则的初始化,如下:
    在这里插入图片描述
    初始化完成我们就可以利用ipset名为blocklist的规则从代码里调用了.
    - 每天都会生成一个ip-block-list-时间.txt的文件(内含新封锁IP,一行一条) 向blocklist里增加IP,所有增加的IP被自动屏蔽(需要reload, 所以代码末尾执行了reload, reload这一步比较费时间超时时间拉长到2分钟)

  • 最后宝塔的"计划任务"里面增加一个bash脚本每日定时执行就可以了
    在这里插入图片描述

  • 实测增加1000个IP耗时12秒,其中分析日志,插入脚本需要2秒,剩下10多秒都是reload防火墙消耗,这个没办法变快. 相比一条一条的增加宝塔的防火墙规则耗时2个小时多, 这个批量增加ipset的办法只用了12秒实在是快太多了. 后来又测试了使用firewalld的rich规则一条一条注入1000条耗时在30秒左右, 所以最终保留了最优方案的代码.

  • 跑了一周了, 每天能封1000个IP…全是这代理池的

  • 发现每天封的IP数量都在下降, 1200->898->763->642… 我倒要看看什么时候能把这家代理池给封干净

  • 观察了3周, 发现代理池的特性是这些IP存活时间不长, 每天封一次阈值太高, 改为1小时封一次了.在这里插入图片描述

  • 更新: 2021年9月5日 通过日志随机多次抽样调查发现已经完全封锁了代理池的IP, 已无A33标志头出现在请求列表中. 通过汇总命令查看blocker.txt并统计( firewall-cmd --permanent --info-ipset=blocklist > block.txt ) 三十天一共封锁 18832 个IP, 表明每小时封1次的策略只需要跑30天就能封干净. 所以至此自动封锁脚本可以关闭了以避免每小时一次的CPU波动.

(全文完)

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
要在微信云服务器上使用宝塔面板搭建基于 Node.js 的小程序后台,您可以按照以下步骤进行操作: 1. 首先,确保您的微信云服务器已经安装了 CentOS 操作系统。如果没有安装,您可以在微信云服务器控制台上选择 CentOS 镜像进行安装。 2. 登录到您的微信云服务器,可以使用 SSH 工具(如 PuTTY)进行连接。 3. 安装宝塔面板。可以使用以下命令在服务器上下载并执行宝塔面板的安装脚本: ``` yum install -y wget && wget -O install.sh http://download.bt.cn/install/install_6.0.sh && sh install.sh ``` 4. 安装完成后,您可以通过浏览器访问服务器的公网 IP 地址加上端口 8888(例如 http://服务器公网IP:8888)来访问宝塔面板。 5. 在浏览器中打开宝塔面板后,按照提示进行初始化设置,包括设置管理员账号和密码等。 6. 在宝塔面板中,选择“软件商店”,然后搜索并安装适合您的 Node.js 运行环境。 7. 安装完成后,您可以在宝塔面板中创建一个网站,并配置域名或使用默认的访问地址。 8. 在您的微信云服务器上,使用 SSH 连接并进入您的网站根目录。一般情况下,宝塔面板会将网站文件存放在`/www/wwwroot/您的域名/public`目录下。 9. 在网站根目录下,使用以下命令初始化一个新的 Node.js 项目: ``` npm init ``` 这将在当前目录下创建一个 `package.json` 文件,用于管理您的 Node.js 项目的依赖和配置。 10. 安装您需要的 Node.js 框架和模块。例如,如果您想使用 Express 框架,可以使用以下命令安装: ``` npm install express ``` 11. 编写您的 Node.js 后台代码,包括路由、控制器、数据库连接等。 12. 在宝塔面板中,找到网站对应的域名配置,设置反向代理规则,将请求转发到 Node.js 项目运行的端口。 13. 启动您的 Node.js 服务器。在网站根目录下执行以下命令: ``` node app.js ``` 这将启动您的 Node.js 项目,并监听来自宝塔面板配置的端口的请求。 现在,您的基于 Node.js 的小程序后台已经搭建完成。您可以通过浏览器访问您的小程序后台,使用宝塔面板中配置的域名或服务器的公网 IP 地址来访问。 请注意,以上步骤仅为搭建 Node.js 小程序后台的基本步骤,具体的操作和配置可能会因您的项目需求而有所不同。建议您在搭建过程中参考官方文档或寻求开发人员的指导。 祝您搭建成功!如果您还有其他问题,请随时提问。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

AKULAKK

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值