一句话需求
- 现在是七月, 从三月开始我的一个网站一直受到几百个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波动.
(全文完)