一句話需求
- 現在是七月, 從三月開始我的一個網站一直受到幾百個IP的流量攻擊, 具體表現就是日志里面出現大量訪問一個固定url網址的不帶reffer的手機端的國內IP段的大量請求. 每秒請求超過50次.
- 一開始用寶塔面板的免費WAF nginx防火墻, 能防住, 但是效果不好, 依然會有大量額外的圖片等資源請求.
- 事件的經過在沒查明IP之前我是不想封的, 因為有些站群的操作手法就是克隆我的網站來引流到他們自己的網站, 這種手法會造成大量訪問我IP的請求都是來自真實用戶的手機. 然后根絕我長期采樣觀察發現這些雖然是真人IP但是應該是來自他們手機里面的后門, 所以流量對我沒有意義, 可以直接封殺掉了.
- 定性之后就要封IP了. nginx那個防火墻不好用, 實際上請求還是請求了nginx的只是nginx回傳了錯誤資訊直接擋住了.所以nginx依然是有負載的.并且防不住變幻請求其他的鬼才知道的哪個頁面.
- 我們要用linux自帶的防火墻來直接封IP才能有效抵御住DDOS. 我使用的是 centos 7.6 系統自帶防火墻是 firewalld , 我禁用了iptable
- 可視化管理是寶塔面板的系統防火墻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
檔案夾結構

原始碼:
/**
* ./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
./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
/*
分析網站日志 main 組
提取非法IP串列
輸出指定格式JSON
**********************使用放方法:
配置config里面的weblog路徑地址, 必須是main路徑或者IP在第一位(日志分隔符是空格), main配置格式見后
首次執行, 執行即可在當前目錄下生成一個JSON檔案, 復制這個檔案內容到寶塔的系統防火墻匯入IP界面即可(匯入之前先洗掉老的JSON, 700個IP耗時半小時, 這個匯入時間很長, 是寶塔的問題)
二次執行, 會生成全新JSON, 包含老的(這里要優化下, 提供只生成新IP的策略JSON)
之后對新weblog分析(含老日志或者全新日志都可以), 會自動對比老組態檔和新weblog, 自動去重已添加的IP并分配新的序列號
*/
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: '/tag/特殊請求url格式/return%20false'
}
}
const getIPList = async () => {
let list = (await fs.readFile(config.logFile, { encoding: 'utf-8' })).split(
'\n'
)
console.log(`總計 ${list.length} 條記錄`)
list = list.filter(item => ~item.indexOf(config.policy.keyword))
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))]
} else {
console.log('沒有找到需要添加的新IP')
}
await writeLog(config.outputFile, JSON.stringify(jsonList), false)
console.timeEnd(timeElapseFlag)
})()
代碼執行
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秒左右, 所以最終保留了最優方案的代碼.
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/291156.html
標籤:其他
上一篇:鏡像的概念
