File size: 3,969 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// wafip.go WAF IP 级拦截 fail-fast 状态机(任务书 waf-ip-failfast)。
//
// 背景(fork-scan-absorb T-1 / BulidH 实测):WAF 403 拦的是网关出口 IP 而非
// 账号——3 个账号 1 秒内全 403。既有 ErrWafBlock 账号级软冷却(wafCooldownBase
// 起的有界退避)在 IP 级拦截时不够:轮转会把一次客户端请求放大 MaxRotate 倍,
// 同一出口 IP 继续打上游只会加重风控。
//
// 判定:ErrWafBlock 基础上的短窗多号计数——wafIPWindow(60s 滑动窗)内
// ≥ wafIPThreshold 个**不同** UID 接连命中 WAF 403 → 判定 IP 级拦截,激活至
// now+wafIPWindow。单号反复 403(账号级偶发)永不触发:只数不同号。
// 激活期内新命中不续期(保守:不做主动探测,窗口自然解除)。
//
// 归属层评估:放 server(Handler 局部)而非 pool——IP 级状态唯一消费者是
// chatCompletions 轮转循环(是否继续轮转),pool 是账号级记账层,跨 UID 语义
// 不属于任何账号;server 已有进程级状态先例 degradeGate(mu+until 同风格)。
// 账号级软冷却照常记账(applyErrorPolicy 不变),IP 级状态只改变「是否继续
// 轮转」——协同不叠加。进程内状态、重启清零(窗口 60s,重建成本极低)。
package server

import (
	"log"
	"sync"
	"time"
)

// wafIPWindow IP 级判定滑动窗 + 激活时长:窗内不同账号命中 WAF 403 达阈值即
// 判 IP 级拦截,激活同样长(到期自然解除)。var 仅供测试注入短窗(生产恒 60s,
// 任务书「如 60s 滑动窗」口径)。
var wafIPWindow = 60 * time.Second

// wafIPThreshold 判定阈值:窗内不同 UID 数达到该值激活。取 2——「多号」的最小
// 定义:单号反复 403 永不触发(账号级偶发归软冷却管),两个不同号在 60s 内接连
// 被拦(同一出口 IP)已是 IP 级证据(BulidH 实测 3 号 1s 全拦,阈值 2 更早止损,
// 少放大一次轮转)。
const wafIPThreshold = 2

// wafIPGate WAF IP 级拦截状态机(Handler 内嵌,零值可用)。
type wafIPGate struct {
	mu    sync.Mutex
	hits  map[string]time.Time // uid → 最近一次 WAF 403 时刻(判定窗内,惰性剪枝)
	until time.Time            // IP 级拦截激活截止;零值 = 未激活
}

// noteWaf 记一次某账号的 WAF 403,返回记账后 IP 级拦截是否激活(调用方据此
// fail-fast 终止轮转,优先于 rotateBackoff 退避)。
//   - 已激活(now < until):不续期、不记账(窗口期内不重置——保守自然解除)→ true;
//   - 未激活:记 hits[uid]=now(同号重复命中覆盖不累计,判定口径是「不同号数」),
//     剪掉窗外的过期命中;不同 UID 数达 wafIPThreshold → 激活到 now+wafIPWindow
//     (打一条 WARN 供观测),清空判定窗(解除后需全新命中重新判定,不叠旧账)。
func (g *wafIPGate) noteWaf(uid string) bool {
	now := time.Now()
	g.mu.Lock()
	defer g.mu.Unlock()
	if now.Before(g.until) {
		return true // 激活期内新命中:不续期(自然解除语义,任务书第 3 条)
	}
	if g.hits == nil {
		g.hits = map[string]time.Time{}
	}
	g.hits[uid] = now
	for u, t := range g.hits {
		if now.Sub(t) > wafIPWindow {
			delete(g.hits, u)
		}
	}
	if len(g.hits) >= wafIPThreshold {
		g.until = now.Add(wafIPWindow)
		log.Printf("WARN: [server] waf ip-level block: %d accounts hit waf 403 within %s, rotate fail-fast until %s", len(g.hits), wafIPWindow, g.until.Format(time.RFC3339))
		g.hits = map[string]time.Time{}
		return true
	}
	return false
}

// active 报告 IP 级拦截是否激活(末端错误文案区分 IP 级/账号级措辞用)。
func (g *wafIPGate) active() bool {
	g.mu.Lock()
	defer g.mu.Unlock()
	return time.Now().Before(g.until)
}