mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-31 02:01:44 +08:00
fix:threatip sync bug
This commit is contained in:
@@ -3,9 +3,12 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"SamWaf/common/zlog"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 说明:Windows 无 ipset 概念,但 netsh 单条规则的 remoteip= 支持逗号分隔多个 IP/CIDR。
|
||||
@@ -18,8 +21,15 @@ const (
|
||||
setRulePrefix = "SamWAF_Set_"
|
||||
// maxRemoteIPChars 单条规则 remoteip= 值的字符上限(留足命令行余量)
|
||||
maxRemoteIPChars = 3000
|
||||
// maxShardScan DestroyIPSet 时扫描删除分片规则的序号上限(兜底防死循环)
|
||||
maxShardScan = 200000
|
||||
// maxShardScan DestroyIPSet 时扫描删除分片规则的序号上限(兜底防死循环)。
|
||||
// 单分片 maxRemoteIPChars=3000 字符 ≈ 200 条 IP,4096 个分片 ≈ 80 万条,远超实际订阅规模。
|
||||
maxShardScan = 4096
|
||||
// destroyMissStreak 连续多少个序号"未命中"就认为分片已删完
|
||||
destroyMissStreak = 3
|
||||
// destroyBudget DestroyIPSet 整体时间预算,超了就放弃剩余扫描(防止拖住调用方持有的锁)
|
||||
destroyBudget = 30 * time.Second
|
||||
// netshCmdTimeout 单条 netsh 命令的超时,防个别调用挂死
|
||||
netshCmdTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// SupportsIPSet 报告当前环境是否可用批量封禁(Windows 下等价于系统防火墙可用,带 30s 缓存)
|
||||
@@ -68,21 +78,66 @@ func (fw *FireWallEngine) FlushIPSet(setName string) error {
|
||||
return fw.DestroyIPSet(setName)
|
||||
}
|
||||
|
||||
// DestroyIPSet 逐序号删除该集合的所有分片规则,直到某序号无匹配为止
|
||||
// DestroyIPSet 逐序号删除该集合的所有分片规则,直到连续若干个序号都删不动为止。
|
||||
//
|
||||
// 停止条件刻意不再依赖 netsh 的本地化提示文案:老实现只认英文
|
||||
// "No rules match the specified criteria" 和简中"没有与指定标准相匹配的规则",
|
||||
// 一旦 netsh 输出别的内容(非中英文系统、非管理员运行时的"请求的操作需要提升"、
|
||||
// 防火墙服务异常等),break 就永远不会触发,会 fork 二十万次 netsh 空转数小时,
|
||||
// 并把调用方持有的同步锁一起焊死。现在改为:**任何一次"没有删成功"都算未命中**,
|
||||
// 连续 destroyMissStreak 次未命中即停,另加整体时间预算兜底。宁可少删,绝不空转。
|
||||
func (fw *FireWallEngine) DestroyIPSet(setName string) error {
|
||||
// 系统防火墙不可用时一次 fork 都不做(netsh 缺失 / 防火墙未启用)
|
||||
if !fw.SupportsIPSet() {
|
||||
return nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
deleted, miss := 0, 0
|
||||
for seq := 0; seq < maxShardScan; seq++ {
|
||||
ruleName := setShardRuleName(setName, seq)
|
||||
cmd := exec.Command("netsh", "advfirewall", "firewall", "delete", "rule", "name="+ruleName)
|
||||
_, output := fw.executeCommand(cmd)
|
||||
if strings.Contains(output, "No rules match the specified criteria") ||
|
||||
strings.Contains(output, "没有与指定标准相匹配的规则") {
|
||||
// 连续遇到无匹配即认为分片已删完
|
||||
if time.Since(start) > destroyBudget {
|
||||
zlog.Warn("清理防火墙分片规则超出时间预算,已提前结束",
|
||||
"set", setName, "scanned", seq, "deleted", deleted,
|
||||
"elapsed", time.Since(start).Round(time.Millisecond).String())
|
||||
break
|
||||
}
|
||||
if fw.deleteShardRule(setName, seq) {
|
||||
deleted++
|
||||
miss = 0
|
||||
continue
|
||||
}
|
||||
miss++
|
||||
if miss >= destroyMissStreak {
|
||||
break
|
||||
}
|
||||
}
|
||||
if deleted > 0 {
|
||||
zlog.Debug("清理防火墙分片规则完成", "set", setName, "deleted", deleted,
|
||||
"elapsed", time.Since(start).Round(time.Millisecond).String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteShardRule 删除单个分片规则,返回是否确实删掉了。
|
||||
// 只有 netsh 明确报告删除成功才算命中;出错、超时、输出看不懂一律当作"没有这个分片"。
|
||||
func (fw *FireWallEngine) deleteShardRule(setName string, seq int) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), netshCmdTimeout)
|
||||
defer cancel()
|
||||
|
||||
ruleName := setShardRuleName(setName, seq)
|
||||
cmd := exec.CommandContext(ctx, "netsh", "advfirewall", "firewall", "delete", "rule", "name="+ruleName)
|
||||
err, output := fw.executeCommand(cmd)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// 已知的"没有这条规则"文案优先判定:部分环境下 netsh 会在该提示后仍打印"确定。"
|
||||
if strings.Contains(output, "No rules match the specified criteria") ||
|
||||
strings.Contains(output, "没有与指定标准相匹配的规则") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(output, "已删除") || strings.Contains(output, "Deleted")
|
||||
}
|
||||
|
||||
// shardIPsByLen 把 IP/CIDR 列表按逗号拼接、累计长度不超过 maxLen 切分为多个分片字符串
|
||||
func shardIPsByLen(ips []string, maxLen int) []string {
|
||||
var shards []string
|
||||
|
||||
@@ -21,6 +21,11 @@ type ThreatIPChannel struct {
|
||||
LastCount int `json:"last_count"` // 上次快照收录条数
|
||||
LastStatus string `gorm:"size:255" json:"last_status"` // 上次同步结果:ok 或错误摘要
|
||||
Remarks string `gorm:"size:500" json:"remarks"` // 备注
|
||||
|
||||
// 以下为运行时内存态字段(gorm:"-" 不落库,无需迁移),仅供列表接口回显"同步中",
|
||||
// 让前端能在后台拉取期间给出反馈并自动轮询,而不是点完什么都看不到。
|
||||
Syncing bool `gorm:"-" json:"syncing"` // 该渠道当前是否有同步在进行
|
||||
SyncStartedAt int64 `gorm:"-" json:"sync_started_at"` // 本次同步开始时间戳(秒),Syncing 为 false 时无意义
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package waf_service
|
||||
|
||||
import (
|
||||
"SamWaf/common/tasklog"
|
||||
"SamWaf/common/uuid"
|
||||
"SamWaf/common/zlog"
|
||||
"SamWaf/customtype"
|
||||
"SamWaf/enums"
|
||||
"SamWaf/firewall"
|
||||
"SamWaf/global"
|
||||
"SamWaf/innerbean"
|
||||
@@ -14,8 +16,10 @@ import (
|
||||
"SamWaf/waftask/threatip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,6 +27,67 @@ import (
|
||||
type WafThreatIPService struct {
|
||||
fw firewall.FireWallEngine
|
||||
syncMu sync.Mutex // 串行化同步,避免多渠道并发拉取造成内核/内存压力
|
||||
|
||||
// inflight 记录"当前正在处理(含排队等锁)的渠道 Code -> 开始时间戳(秒)"。
|
||||
// 纯内存态(重启即清空),只用于列表接口回显"同步中",让前端能轮询到结果。
|
||||
inflight sync.Map
|
||||
// lockHolder 当前持有 syncMu 的操作描述,用于在别人拿不到锁时说清楚"是谁占着"
|
||||
lockHolder atomic.Value
|
||||
}
|
||||
|
||||
// lockWaitOnDemand 等待 syncMu 的上限。
|
||||
// 超过就明确报错返回,而不是像以前那样无限期静默排队(用户会看到"提示已开始"却永远没结果)。
|
||||
// 取 30s:既能容纳"连着点两个渠道同步"这种正常排队,又不会让失败无限期悬着。
|
||||
const lockWaitOnDemand = 30 * time.Second
|
||||
|
||||
// syncTriggerManual / syncTriggerSchedule 同步触发方式,进日志与 last_status 便于区分
|
||||
const (
|
||||
syncTriggerManual = "手动"
|
||||
syncTriggerSchedule = "定时"
|
||||
)
|
||||
|
||||
// tryLockSync 在 wait 时间内尝试获取同步锁,holder 描述本次操作(用于让后来者知道是谁占着)。
|
||||
// 用轮询式 TryLock 而非阻塞 Lock:这条链路上任何一次卡死都会把后续所有同步永久堵住,
|
||||
// 宁可明确失败并留下日志,也不要无限期静默排队。
|
||||
func (r *WafThreatIPService) tryLockSync(wait time.Duration, holder string) bool {
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
if r.syncMu.TryLock() {
|
||||
r.lockHolder.Store(holder)
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// unlockSync 释放同步锁并清掉持有者标记
|
||||
func (r *WafThreatIPService) unlockSync() {
|
||||
r.lockHolder.Store("")
|
||||
r.syncMu.Unlock()
|
||||
}
|
||||
|
||||
// busyHint 描述当前占着同步锁的操作,用于错误信息与日志
|
||||
func (r *WafThreatIPService) busyHint() string {
|
||||
if h, _ := r.lockHolder.Load().(string); h != "" {
|
||||
return h
|
||||
}
|
||||
return "有其它订阅落地操作正在进行"
|
||||
}
|
||||
|
||||
// safeGo 起一个带 panic 兜底的后台 goroutine。
|
||||
// 以前这里是裸 go func():一次 panic 会直接崩掉整个 SamWaf 进程,且什么线索都不留。
|
||||
func safeGo(name string, fn func()) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
zlog.Error(fmt.Sprintf("威胁情报订阅后台任务[%s]异常: %v 调试信息:%s", name, e, debug.Stack()))
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
var WafThreatIPServiceApp = new(WafThreatIPService)
|
||||
@@ -100,21 +165,28 @@ func (r *WafThreatIPService) ModifyApi(req request.WafThreatIPChannelEditReq) er
|
||||
code := bean.Code
|
||||
enable := req.Enable
|
||||
name := req.Name
|
||||
go func() {
|
||||
r.applyEnableState(code, landTarget, enable)
|
||||
r.notifyOpResult("威胁情报订阅保存生效", name, nil)
|
||||
}()
|
||||
safeGo("保存生效:"+code, func() {
|
||||
err := r.applyEnableState(code, landTarget, enable)
|
||||
r.notifyOpResult("威胁情报订阅保存生效", name, err)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyEnableState 依据启用状态把某渠道落地到位:系统集合的增删 + 重建 WAF 应用层并集。
|
||||
// 供编辑保存后异步调用;串行化(syncMu)避免与同步任务并发改动内核/内存态。
|
||||
func (r *WafThreatIPService) applyEnableState(code, landTarget string, enable int) {
|
||||
r.syncMu.Lock()
|
||||
defer r.syncMu.Unlock()
|
||||
func (r *WafThreatIPService) applyEnableState(code, landTarget string, enable int) error {
|
||||
start := time.Now()
|
||||
if !r.tryLockSync(lockWaitOnDemand, fmt.Sprintf("渠道[%s]保存生效", code)) {
|
||||
msg := "订阅落地生效跳过:" + r.busyHint()
|
||||
zlog.Warn("威胁情报订阅落地生效未能获取同步锁", "channel", code, "detail", r.busyHint())
|
||||
return errors.New(msg)
|
||||
}
|
||||
defer r.unlockSync()
|
||||
|
||||
zlog.Info("威胁情报订阅落地生效开始", "channel", code, "land", landTarget, "enable", enable)
|
||||
if enable == 0 {
|
||||
// 停用:清理系统集合(WAF 并集在下方重建时自动排除该渠道)
|
||||
r.fw.DestroyIPSet(setNameForChannel(code))
|
||||
r.destroySystemSet(code)
|
||||
} else if landTarget == model.ThreatLandSystem || landTarget == model.ThreatLandBoth {
|
||||
// 启用且落地含系统层:把快照重新灌回系统集合(重启/停用会丢,需按需回灌)
|
||||
if r.fw.SupportsIPSet() {
|
||||
@@ -123,12 +195,29 @@ func (r *WafThreatIPService) applyEnableState(code, landTarget string, enable in
|
||||
zlog.Warn("启用渠道系统层落地失败", "channel", code, "error", err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
zlog.Warn("当前环境不支持 ipset 批量封禁,跳过系统层落地", "channel", code)
|
||||
}
|
||||
} else {
|
||||
// 启用但落地不含系统层:清理可能残留的系统集合
|
||||
r.fw.DestroyIPSet(setNameForChannel(code))
|
||||
r.destroySystemSet(code)
|
||||
}
|
||||
r.RebuildWAFUnion()
|
||||
zlog.Info("威胁情报订阅落地生效完成", "channel", code, "elapsed", time.Since(start).Round(time.Millisecond).String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// destroySystemSet 清理某渠道在系统防火墙上的集合。
|
||||
// 只在系统防火墙确实可用时才动手:纯 WAF 落地的渠道从没往系统层写过东西,
|
||||
// 无条件调用只会白白 fork 一堆 iptables/netsh(Windows 上还可能长时间空转并占着同步锁)。
|
||||
func (r *WafThreatIPService) destroySystemSet(code string) {
|
||||
if !r.fw.SupportsIPSet() {
|
||||
zlog.Debug("系统防火墙不可用,跳过系统层集合清理", "channel", code)
|
||||
return
|
||||
}
|
||||
if err := r.fw.DestroyIPSet(setNameForChannel(code)); err != nil {
|
||||
zlog.Warn("清理系统层集合失败", "channel", code, "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// notifyOpResult 把某异步操作结果推送到管理端通知中心(WebSocket 实时)。
|
||||
@@ -164,13 +253,18 @@ func (r *WafThreatIPService) DelApi(id string) error {
|
||||
return err
|
||||
}
|
||||
// 异步落地:撤系统集合 + 重建并集
|
||||
go func() {
|
||||
r.syncMu.Lock()
|
||||
r.fw.DestroyIPSet(setNameForChannel(code))
|
||||
safeGo("删除生效:"+code, func() {
|
||||
if !r.tryLockSync(lockWaitOnDemand, fmt.Sprintf("渠道[%s]删除生效", code)) {
|
||||
zlog.Warn("威胁情报订阅删除生效未能获取同步锁", "channel", code, "detail", r.busyHint())
|
||||
r.notifyOpResult("威胁情报订阅删除生效", name, errors.New("落地生效跳过:"+r.busyHint()+";下次同步或重启后会自动对齐"))
|
||||
return
|
||||
}
|
||||
defer r.unlockSync() // 用 defer:以前是手动 Unlock,中途 panic 会永久泄漏锁,之后所有同步全卡死
|
||||
r.destroySystemSet(code)
|
||||
r.RebuildWAFUnion()
|
||||
r.syncMu.Unlock()
|
||||
zlog.Info("威胁情报订阅删除生效完成", "channel", code)
|
||||
r.notifyOpResult("威胁情报订阅删除生效", name, nil)
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -189,10 +283,21 @@ func (r *WafThreatIPService) GetListApi(req request.WafThreatIPChannelSearchReq)
|
||||
if c := r.snapshotCount(list[i].Code); c >= 0 {
|
||||
list[i].LastCount = c
|
||||
}
|
||||
// 回填内存态"同步中",前端据此显示进行中并自动轮询
|
||||
list[i].Syncing, list[i].SyncStartedAt = r.syncingOf(list[i].Code)
|
||||
}
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
// syncingOf 查某渠道当前是否正在同步,以及本次同步的开始时间戳
|
||||
func (r *WafThreatIPService) syncingOf(code string) (bool, int64) {
|
||||
if v, ok := r.inflight.Load(code); ok {
|
||||
startedAt, _ := v.(int64)
|
||||
return true, startedAt
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// ---------------- 订阅落地只读浏览(方案三:原页"订阅来源"Tab) ----------------
|
||||
|
||||
// LandedChannelSummary 某渠道在指定层的落地汇总
|
||||
@@ -201,10 +306,12 @@ type LandedChannelSummary struct {
|
||||
Name string `json:"name"`
|
||||
LandTarget string `json:"land_target"`
|
||||
Enable int `json:"enable"`
|
||||
Count int `json:"count"` // 实际生效(已落地)条数:启用=快照条数,停用=0(已从防火墙/WAF移除)
|
||||
SnapshotCount int `json:"snapshot_count"` // 快照收录条数(不论启用与否,供停用时提示"再启用可回灌")
|
||||
LastSyncAt int64 `json:"last_sync_at"` // 上次同步时间戳(秒)
|
||||
LastStatus string `json:"last_status"` // 上次同步结果
|
||||
Count int `json:"count"` // 实际生效(已落地)条数:启用=快照条数,停用=0(已从防火墙/WAF移除)
|
||||
SnapshotCount int `json:"snapshot_count"` // 快照收录条数(不论启用与否,供停用时提示"再启用可回灌")
|
||||
LastSyncAt int64 `json:"last_sync_at"` // 上次同步时间戳(秒)
|
||||
LastStatus string `json:"last_status"` // 上次同步结果
|
||||
Syncing bool `json:"syncing"` // 当前是否有同步在进行(内存态)
|
||||
SyncStartedAt int64 `json:"sync_started_at"` // 本次同步开始时间戳(秒)
|
||||
}
|
||||
|
||||
// GetLandedSummary 返回落地到指定层的各渠道汇总。
|
||||
@@ -226,6 +333,7 @@ func (r *WafThreatIPService) GetLandedSummary(land string) []LandedChannelSummar
|
||||
if ch.Enable == 0 {
|
||||
landed = 0
|
||||
}
|
||||
syncing, syncStartedAt := r.syncingOf(ch.Code)
|
||||
out = append(out, LandedChannelSummary{
|
||||
Code: ch.Code,
|
||||
Name: ch.Name,
|
||||
@@ -235,6 +343,8 @@ func (r *WafThreatIPService) GetLandedSummary(land string) []LandedChannelSummar
|
||||
SnapshotCount: snapCnt,
|
||||
LastSyncAt: ch.LastSyncAt,
|
||||
LastStatus: ch.LastStatus,
|
||||
Syncing: syncing,
|
||||
SyncStartedAt: syncStartedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -308,47 +418,100 @@ func (r *WafThreatIPService) SyncByIdApi(id string) error {
|
||||
if err := global.GWAF_LOCAL_DB.Where("id = ?", id).First(&bean).Error; err != nil {
|
||||
return errors.New("记录不存在")
|
||||
}
|
||||
go func() {
|
||||
err := r.SyncChannel(bean)
|
||||
zlog.Info("威胁情报订阅收到手动同步请求", "channel", bean.Code, "name", bean.Name,
|
||||
"url", bean.URL, "parser", bean.ParserType, "land", bean.LandTarget)
|
||||
safeGo("手动同步:"+bean.Code, func() {
|
||||
err := r.syncChannelWithTrigger(bean, syncTriggerManual)
|
||||
r.notifyOpResult("威胁情报订阅同步", bean.Name, err)
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncChannel 拉取→解析→与上次快照比对→(突变保护)→保存快照→落地(系统 ipset + WAF 并集)。
|
||||
func (r *WafThreatIPService) SyncChannel(ch model.ThreatIPChannel) error {
|
||||
r.syncMu.Lock()
|
||||
defer r.syncMu.Unlock()
|
||||
return r.syncChannelWithTrigger(ch, syncTriggerSchedule)
|
||||
}
|
||||
|
||||
raw, err := threatip.Fetch(ch.URL)
|
||||
// syncChannelWithTrigger 同 SyncChannel,额外带上触发方式(手动/定时)用于日志与状态回显。
|
||||
//
|
||||
// 这条链路以前失败时是完全静默的(四个失败分支都只写 DB、不打日志,失败通知又只走一次性 WebSocket),
|
||||
// 用户点完"立即同步"只能看到"已开始"然后什么都没有。所以这里每个阶段都留了日志与耗时,
|
||||
// 并把手动触发也挂进任务日志,方便在"任务日志"页直接看到全过程。
|
||||
func (r *WafThreatIPService) syncChannelWithTrigger(ch model.ThreatIPChannel, trigger string) error {
|
||||
// 让本 goroutine 的 zlog 输出同时落到任务日志文件(与定时任务同一个任务名)。
|
||||
// 定时路径下 ExecuteTask 已经设过任务上下文,这里就不能重复设——否则同步完第一个渠道时
|
||||
// 的 defer Clear 会把整个 SyncAllDue 剩余过程的任务日志上下文一起清掉。
|
||||
if _, ok := tasklog.GetCurrentTask(); !ok {
|
||||
tasklog.SetCurrentTask(enums.TASK_THREAT_IP_SYNC)
|
||||
defer tasklog.ClearCurrentTask()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// 先标记"同步中"再去抢锁:排队等锁的这段时间前端也该看到进行中,
|
||||
// 否则轮询会以为已经结束而提前收工。无论从哪个分支返回都会清掉。
|
||||
r.inflight.Store(ch.Code, start.Unix())
|
||||
defer r.inflight.Delete(ch.Code)
|
||||
|
||||
if !r.tryLockSync(lockWaitOnDemand, fmt.Sprintf("渠道[%s]%s同步", ch.Code, trigger)) {
|
||||
msg := fmt.Sprintf("已跳过(%s触发):等待 %s 仍未轮到,%s", trigger, lockWaitOnDemand, r.busyHint())
|
||||
zlog.Warn("威胁情报订阅同步未能获取同步锁", "channel", ch.Code, "trigger", trigger, "detail", r.busyHint())
|
||||
r.markSyncFail(ch.Id, msg)
|
||||
return errors.New(msg)
|
||||
}
|
||||
defer r.unlockSync()
|
||||
|
||||
zlog.Info("威胁情报订阅同步开始", "channel", ch.Code, "trigger", trigger, "url", ch.URL, "parser", ch.ParserType,
|
||||
"waited", time.Since(start).Round(time.Millisecond).String())
|
||||
|
||||
raw, stat, err := threatip.FetchWithStat(ch.URL)
|
||||
if err != nil {
|
||||
r.markSyncStatus(ch.Id, "拉取失败: "+err.Error(), 0)
|
||||
zlog.Error(fmt.Sprintf("威胁情报订阅拉取失败 channel=%s trigger=%s error=%s", ch.Code, trigger, err.Error()))
|
||||
r.markSyncFail(ch.Id, "拉取失败: "+err.Error())
|
||||
return err
|
||||
}
|
||||
zlog.Info("威胁情报订阅拉取完成", "channel", ch.Code, "status", stat.StatusCode,
|
||||
"bytes", stat.Bytes, "elapsed", stat.Elapsed.Round(time.Millisecond).String())
|
||||
|
||||
parseStart := time.Now()
|
||||
parseRes, err := threatip.ParseByType(ch.ParserType, strings.NewReader(string(raw)), ch.Threshold)
|
||||
if err != nil {
|
||||
r.markSyncStatus(ch.Id, "解析失败: "+err.Error(), 0)
|
||||
zlog.Error(fmt.Sprintf("威胁情报订阅解析失败 channel=%s parser=%s error=%s", ch.Code, ch.ParserType, err.Error()))
|
||||
r.markSyncFail(ch.Id, "解析失败: "+err.Error())
|
||||
return err
|
||||
}
|
||||
newIPs := parseRes.IPs
|
||||
zlog.Info("威胁情报订阅解析完成", "channel", ch.Code, "valid", len(newIPs), "dropped", parseRes.Dropped,
|
||||
"elapsed", time.Since(parseStart).Round(time.Millisecond).String())
|
||||
|
||||
// 源返回 200 但一条有效数据都没有(常见于返回了验证页/错误页),按失败处理,避免把空快照当成功
|
||||
if len(newIPs) == 0 {
|
||||
msg := fmt.Sprintf("解析结果为空(丢弃非法行 %d,响应 %d 字节),可能订阅源返回了非预期内容或解析格式选错", parseRes.Dropped, stat.Bytes)
|
||||
zlog.Error(fmt.Sprintf("威胁情报订阅解析结果为空 channel=%s detail=%s", ch.Code, msg))
|
||||
r.markSyncFail(ch.Id, msg)
|
||||
return errors.New(msg)
|
||||
}
|
||||
|
||||
// 载入上次快照做突变保护与 sha 比对
|
||||
oldIPs, oldSha := r.loadSnapshot(ch.Code)
|
||||
if len(oldIPs) > shrinkGuardMin && len(newIPs) < len(oldIPs)/shrinkGuardRatio {
|
||||
msg := fmt.Sprintf("疑似源异常:新快照 %d 条 远少于上次 %d 条,已跳过应用(丢弃非法行 %d)", len(newIPs), len(oldIPs), parseRes.Dropped)
|
||||
zlog.Warn("威胁情报订阅突变保护", "channel", ch.Code, "detail", msg)
|
||||
r.markSyncStatus(ch.Id, msg, len(oldIPs))
|
||||
r.markSyncFail(ch.Id, msg)
|
||||
return errors.New(msg)
|
||||
}
|
||||
|
||||
payload, sha, count, err := threatip.EncodeSnapshot(newIPs)
|
||||
if err != nil {
|
||||
r.markSyncStatus(ch.Id, "快照编码失败: "+err.Error(), 0)
|
||||
zlog.Error(fmt.Sprintf("威胁情报订阅快照编码失败 channel=%s error=%s", ch.Code, err.Error()))
|
||||
r.markSyncFail(ch.Id, "快照编码失败: "+err.Error())
|
||||
return err
|
||||
}
|
||||
if sha == oldSha && oldSha != "" {
|
||||
// 无变化:仅更新同步时间
|
||||
r.markSyncStatus(ch.Id, "ok(无变化)", count)
|
||||
zlog.Info("威胁情报订阅内容无变化,跳过落地", "channel", ch.Code, "count", count,
|
||||
"elapsed", time.Since(start).Round(time.Millisecond).String())
|
||||
r.markSyncOK(ch.Id, fmt.Sprintf("ok(无变化,%s触发,耗时%s)", trigger, time.Since(start).Round(time.Millisecond)), count)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -356,29 +519,36 @@ func (r *WafThreatIPService) SyncChannel(ch model.ThreatIPChannel) error {
|
||||
|
||||
// 保存新快照(替换该渠道旧快照)
|
||||
if err := r.saveSnapshot(ch.Code, payload, sha, count); err != nil {
|
||||
r.markSyncStatus(ch.Id, "保存快照失败: "+err.Error(), 0)
|
||||
zlog.Error(fmt.Sprintf("威胁情报订阅保存快照失败 channel=%s error=%s", ch.Code, err.Error()))
|
||||
r.markSyncFail(ch.Id, "保存快照失败: "+err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// 落地系统防火墙(该渠道私有集合,全量重建)
|
||||
if ch.LandTarget == model.ThreatLandSystem || ch.LandTarget == model.ThreatLandBoth {
|
||||
if r.fw.SupportsIPSet() {
|
||||
landStart := time.Now()
|
||||
if err := r.fw.RestoreIPSet(setNameForChannel(ch.Code), newIPs); err != nil {
|
||||
zlog.Warn("威胁情报系统层落地失败", "channel", ch.Code, "error", err.Error())
|
||||
} else {
|
||||
zlog.Info("威胁情报系统层落地完成", "channel", ch.Code, "count", count,
|
||||
"elapsed", time.Since(landStart).Round(time.Millisecond).String())
|
||||
}
|
||||
} else {
|
||||
zlog.Warn("当前环境不支持 ipset 批量封禁,跳过系统层落地", "channel", ch.Code)
|
||||
}
|
||||
} else {
|
||||
// 落地目标不含系统层:清理可能残留的系统集合
|
||||
r.fw.DestroyIPSet(setNameForChannel(ch.Code))
|
||||
// 落地目标不含系统层:清理可能残留的系统集合(系统防火墙不可用时内部会直接跳过)
|
||||
r.destroySystemSet(ch.Code)
|
||||
}
|
||||
|
||||
// 落地 WAF 应用层(重建全局并集)
|
||||
r.RebuildWAFUnion()
|
||||
|
||||
r.markSyncStatus(ch.Id, fmt.Sprintf("ok(+%d/-%d,丢弃%d)", len(added), len(removed), parseRes.Dropped), count)
|
||||
zlog.Info("威胁情报订阅同步完成", "channel", ch.Code, "count", count, "added", len(added), "removed", len(removed))
|
||||
elapsed := time.Since(start).Round(time.Millisecond)
|
||||
r.markSyncOK(ch.Id, fmt.Sprintf("ok(+%d/-%d,丢弃%d,%s触发,耗时%s)", len(added), len(removed), parseRes.Dropped, trigger, elapsed), count)
|
||||
zlog.Info("威胁情报订阅同步完成", "channel", ch.Code, "trigger", trigger, "count", count,
|
||||
"added", len(added), "removed", len(removed), "elapsed", elapsed.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -405,8 +575,10 @@ func (r *WafThreatIPService) RebuildWAFUnion() {
|
||||
for ip := range uniq {
|
||||
items = append(items, ip)
|
||||
}
|
||||
buildStart := time.Now()
|
||||
ipset.SetGlobalThreatMatcher(ipset.BuildMatchSet(items))
|
||||
zlog.Info("威胁情报 WAF 并集已重建", "total", len(items))
|
||||
zlog.Info("威胁情报 WAF 并集已重建", "total", len(items),
|
||||
"elapsed", time.Since(buildStart).Round(time.Millisecond).String())
|
||||
}
|
||||
|
||||
// RestoreAllOnStartup 进程启动时重放:把各启用渠道的快照重新灌入系统 ipset(内存态,重启会丢),
|
||||
@@ -436,15 +608,18 @@ func (r *WafThreatIPService) SyncAllDue() {
|
||||
var channels []model.ThreatIPChannel
|
||||
global.GWAF_LOCAL_DB.Where("enable = 1").Find(&channels)
|
||||
now := time.Now().Unix()
|
||||
due := 0
|
||||
for _, ch := range channels {
|
||||
interval := int64(defaultInt(ch.IntervalHour, 24)) * 3600
|
||||
if ch.LastSyncAt > 0 && now-ch.LastSyncAt < interval {
|
||||
continue // 未到期
|
||||
}
|
||||
if err := r.SyncChannel(ch); err != nil {
|
||||
due++
|
||||
if err := r.syncChannelWithTrigger(ch, syncTriggerSchedule); err != nil {
|
||||
zlog.Warn("威胁情报订阅同步失败", "channel", ch.Code, "error", err.Error())
|
||||
}
|
||||
}
|
||||
zlog.Info("威胁情报订阅定时检查完成", "enabled", len(channels), "due", due)
|
||||
}
|
||||
|
||||
// ---------------- 内部辅助 ----------------
|
||||
@@ -484,14 +659,32 @@ func (r *WafThreatIPService) saveSnapshot(code string, payload []byte, sha strin
|
||||
return global.GWAF_LOCAL_DB.Create(snap).Error
|
||||
}
|
||||
|
||||
// markSyncStatus 更新渠道同步状态字段
|
||||
func (r *WafThreatIPService) markSyncStatus(id, status string, count int) {
|
||||
if err := global.GWAF_LOCAL_DB.Model(&model.ThreatIPChannel{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"LastSyncAt": time.Now().Unix(),
|
||||
"LastCount": count,
|
||||
"LastStatus": truncateStatus(status),
|
||||
"UPDATE_TIME": customtype.JsonTime(time.Now()),
|
||||
}).Error; err != nil {
|
||||
// markSyncOK 同步成功后回写:刷新同步时间、收录条数与状态
|
||||
func (r *WafThreatIPService) markSyncOK(id, status string, count int) {
|
||||
r.updateSyncFields(id, map[string]interface{}{
|
||||
"LastSyncAt": time.Now().Unix(),
|
||||
"LastCount": count,
|
||||
"LastStatus": truncateStatus(status),
|
||||
})
|
||||
}
|
||||
|
||||
// markSyncFail 同步失败后回写:只写状态原因。
|
||||
//
|
||||
// 刻意不动 LastSyncAt、LastCount:
|
||||
// - 以前失败也把 LastSyncAt 刷成当前时间,SyncAllDue 的到期判断就把失败当成了成功,
|
||||
// 导致整整一个周期(默认 24h)不再重试——这正是用户说的"后面再也没成功";
|
||||
// 不刷新之后,下一次每小时的定时任务会自然重试。
|
||||
// - 以前失败还会把 LastCount 抹成 0,把上一次的真实条数丢掉。
|
||||
func (r *WafThreatIPService) markSyncFail(id, status string) {
|
||||
r.updateSyncFields(id, map[string]interface{}{
|
||||
"LastStatus": truncateStatus(status),
|
||||
})
|
||||
}
|
||||
|
||||
// updateSyncFields 统一回写同步相关字段(自动补 UPDATE_TIME)
|
||||
func (r *WafThreatIPService) updateSyncFields(id string, fields map[string]interface{}) {
|
||||
fields["UPDATE_TIME"] = customtype.JsonTime(time.Now())
|
||||
if err := global.GWAF_LOCAL_DB.Model(&model.ThreatIPChannel{}).Where("id = ?", id).Updates(fields).Error; err != nil {
|
||||
zlog.Warn("威胁情报订阅状态回写失败", "id", id, "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -13,25 +14,66 @@ var fetchClient = &http.Client{Timeout: 2 * time.Minute}
|
||||
// maxFetchBytes 单次拉取的响应体上限(防超大/投毒响应耗尽内存):64MB
|
||||
const maxFetchBytes = 64 * 1024 * 1024
|
||||
|
||||
// bodyPeekLen 非 200 时截取响应正文做摘要的长度(用于区分"真的挂了"与"返回了HTML错误页/验证页")
|
||||
const bodyPeekLen = 200
|
||||
|
||||
// FetchStat 一次拉取的可观测信息,供调用方打日志/回写状态。
|
||||
type FetchStat struct {
|
||||
StatusCode int // HTTP 状态码,0 表示连接阶段就失败
|
||||
Bytes int // 实际读取到的字节数
|
||||
Elapsed time.Duration // 整体耗时(含连接、TLS、读完 body)
|
||||
}
|
||||
|
||||
// Fetch 拉取订阅源原始内容。限制最大读取字节数,返回原始 body 字节。
|
||||
// 注意:调用方需确保 url 已登记进对外访问白名单(见 CLAUDE.md),且订阅默认关闭、由用户显式启用。
|
||||
func Fetch(url string) ([]byte, error) {
|
||||
data, _, err := FetchWithStat(url)
|
||||
return data, err
|
||||
}
|
||||
|
||||
// FetchWithStat 与 Fetch 相同,但额外返回本次拉取的状态码/字节数/耗时。
|
||||
// 失败时错误信息里会带上 URL、耗时与(非 200 时的)响应正文摘要——这条链路以前失败是完全静默的,
|
||||
// 用户只能看到"没反应",所以这里刻意把上下文塞足,便于日志与"上次状态"里直接看出原因。
|
||||
func FetchWithStat(url string) ([]byte, FetchStat, error) {
|
||||
start := time.Now()
|
||||
stat := FetchStat{}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
stat.Elapsed = time.Since(start)
|
||||
return nil, stat, fmt.Errorf("构造请求失败(url=%s): %v", url, err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "SamWaf-ThreatIP")
|
||||
|
||||
resp, err := fetchClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
stat.Elapsed = time.Since(start)
|
||||
return nil, stat, fmt.Errorf("请求失败(url=%s, 耗时=%s): %v", url, stat.Elapsed.Round(time.Millisecond), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
stat.StatusCode = resp.StatusCode
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("拉取失败,HTTP 状态码: %d", resp.StatusCode)
|
||||
peek, _ := io.ReadAll(io.LimitReader(resp.Body, bodyPeekLen))
|
||||
stat.Elapsed = time.Since(start)
|
||||
return nil, stat, fmt.Errorf("HTTP 状态码 %d(url=%s, 耗时=%s, 正文摘要=%q)",
|
||||
resp.StatusCode, url, stat.Elapsed.Round(time.Millisecond), summarize(peek))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes))
|
||||
stat.Bytes = len(data)
|
||||
stat.Elapsed = time.Since(start)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, stat, fmt.Errorf("读取响应失败(url=%s, 已读=%d字节, 耗时=%s): %v",
|
||||
url, stat.Bytes, stat.Elapsed.Round(time.Millisecond), err)
|
||||
}
|
||||
return data, nil
|
||||
return data, stat, nil
|
||||
}
|
||||
|
||||
// summarize 把响应片段压成单行摘要(去换行、压空白),避免污染日志与 last_status
|
||||
func summarize(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user