这段时间我在开发和完善我的玩具项目GopherInk,处理程序被CC攻击时,WAF的表现情况。前面几次改进我已经尽可能优化了应用层的封禁逻辑,把封禁逻辑放在处理请求的解析客户端IP后面,如果匹配了就直接封禁返回429。但是,我发现即使这样,被单点暴力CC攻击的时候,还是会造成显著的CPU和内存占用,这违背了WAF防护的初衷。我希望是可以挡下暴力单点IP的CC攻击,但是目前看这种单点CC就可以把GopherInk玩死,这就没意思了。
虽然ChatGPT给的建议是让Nginx完成L4阻断,但是本来我希望这个程序是足够灵活:既可以在Nginx后面通过Nginx反代部署,设置速率和并发限制;也可以直接单应用裸部署到服务器上面,实现零配置部署。显然,后者会出问题,我打算解决这个问题。
原因分析
为了解决这个问题,我询问了当前这个程序的运行逻辑和Nginx有什么区别,随后分析出了原因:封禁发生在 L7,但连接的开销发生在 L4/L6
Go 的 net/http server 处理一个请求的完整路径是这样的:
客户端
│ TCP SYN
▼
操作系统内核 ← TCP 三次握手(SYN/ACK,分配 socket、accept 队列)
│ accept()
▼
net.Listener.Accept() ← Go 用户态拿到 *net.TCPConn
│
▼
http.Server.Serve() ← 为每个连接 spawn 一个 goroutine
│
▼
TLS 握手(如果启用) ← 分配 TLS 状态机、读 ClientHello、证书校验
│
▼
HTTP 请求头解析 ← 分配 bufio.Reader、解析 method/path/headers
│
▼
Handler 链 ← 终于进入 WAF 中间件
│
▼
WAF: isBanned(ip)? → 429 ← 封禁检查在这里!旧实现把封禁检查放在 Handler 链里,也就是 L7(HTTP 应用层)。这意味着一个被封禁 IP 的请求,在到达封禁检查之前,已经走完了:
- TCP 三次握手——内核分配 socket buffer、占用 accept 队列
Accept()返回 + goroutine 创建——Go runtime 为这个连接分配一个 goroutine(初始栈 2~8KB)- TLS 握手(如果启用了 HTTPS)——分配 TLS 状态机、读 ClientHello、做非对称运算
- HTTP 请求头解析——分配
bufio.Reader、解析请求行和所有头 http.Request对象构造——一堆 string 分配
这些开销全部发生在封禁检查之前。WAF返回429只是省下了后面的业务逻辑(数据库查询、模板渲染),但前面那一大坨连接建立和协议解析的开销一点也没省。这么看,被单IP暴力CC时CPU和内存占用飙升也是“合情合理”了。
更要命的是 keep-alive。HTTP/1.1 默认长连接,一个 TCP 连接可以连续发几十上百个请求。旧实现封禁后只是"每个请求返回429",连接本身不会被关闭。攻击者复用一个连接持续发请求,每个请求都要重新走HTTP解析 + WAF检查 + 响应写入,goroutine和缓冲区一直占着不释放。
把封禁检查在Handler链里提前到最前面能省掉业务逻辑开销,但省不掉 TLS 握手和 HTTP 解析——因为这两步在请求进入Handler之前就已经被http.Server做完了。这是net/http的固有行为:你拿到*http.Request的时候,连接和请求头都已经解析好了。
这就是问题的本质:只要封禁检查在http.Handler里,它就永远是L7的,永远晚于连接建立和协议解析,而大量的连接建立会拖垮性能。
既然已经查明了原因,解决问题的方法就显而易见了——把封禁提前到L4网络层,建立连接之前就直接把恶意IP的请求在网络层直接丢弃,不进入应用层。
改进思路:把封禁下沉到net.Listener层
要真正切断开销,必须把封禁检查提到http.Server之前——提到net.Listener.Accept()这一层。net.Listener是Go网络栈的L4抽象。http.Server.Serve(l)的工作循环就是不断调用l.Accept()拿连接,然后spawn goroutine处理。如果我们包装一层Listener,在 Accept()返回连接之前就检查来源 IP,命中封禁就直接conn.Close()并继续循环,那么这个连接永远不会进入http.Server的处理流程:
客户端
│ TCP SYN
▼
内核(握手、socket)
│ accept()
▼
FilteringListener.Accept() ← 在这里检查 IP!
│
├── 命中封禁 → conn.Close() → continue(不返回给 http.Server)
│
└── 正常 → 返回 trackedConn
▼
http.Server.Serve() ← 只有正常连接才到这里
│
▼
TLS 握手 + HTTP 解析 + Handler被封禁IP的连接在Accept阶段就被 Close,不分配 goroutine、不执行TLS握手、不解析HTTP、不构造http.Request。开销从"完整协议栈"降到"一次IP查表 + 一次系统调用close"。
实现网络层封禁处理
新增的pkg/connfilter/listener.go只有一百多行,核心是一个包装了net.Listener的结构:
// pkg/connfilter/listener.go
type IPBlacklist interface {
Check(ip net.IP, now time.Time) HitResult
CheckAndRecordHit(ip net.IP, now time.Time) HitResult
}
type FilteringListener struct {
Inner net.Listener // 被包装的真实 listener
Blacklist IPBlacklist // 黑名单查询接口
connectionsMu sync.Mutex
connections map[string]map[*trackedConn]struct{} // 按IP分桶的活跃连接注册表
}Accept:在连接进入HTTP server之前过滤
func (l *FilteringListener) Accept() (net.Conn, error) {
for {
conn, err := l.Inner.Accept()
if err != nil {
return nil, err
}
ip := remoteIP(conn.RemoteAddr())
if ip == nil || l.Blacklist == nil {
return conn, nil
}
// 第一次检查:计数并判断是否封禁
result := l.Blacklist.CheckAndRecordHit(ip, time.Now())
if result.Blocked {
_ = conn.Close() // ← 关键:立即关闭,不返回给 http.Server
continue // ← 继续循环等下一个连接
}
tracked := &trackedConn{Conn: conn, owner: l, ip: normalizedIP(ip)}
l.register(tracked)
// 第二次检查(不计数):关闭注册和首次检查之间的竞态窗口
if result = l.Blacklist.Check(ip, time.Now()); result.Blocked {
_ = tracked.Close()
continue
}
return tracked, nil
}
}几个值得注意的细节:
conn.Close()后continue:被拒连接不会返回给http.Server,循环继续Accept下一个。对http.Server来说,被拒连接就像从来没存在过。- 两次检查防竞态:第一次
CheckAndRecordHit(会记录命中用于续期)和register之间,可能有其他 goroutine 刚好把这个 IP 写入封禁。注册后再做一次Check(不计数),如果此时被封就关闭。这避免了"刚注册的连接绕过新封禁"的窗口,又不会重复计数。 - 返回
trackedConn而非原始conn:trackedConn包装了net.Conn,在Close时把自己从注册表里摘除。这个注册表是CloseIP能主动关闭已建立连接的关键。
trackedConn:让已建立连接可被精确关闭
type trackedConn struct {
net.Conn
owner *FilteringListener
ip string
closeOnce sync.Once
closeErr error
}
func (c *trackedConn) Close() error {
c.closeOnce.Do(func() {
c.owner.unregister(c) // 从注册表摘除
c.closeErr = c.Conn.Close()
})
return c.closeErr
}每个被 Accept 返回的连接都是一个 trackedConn,按 IP 分桶注册在 connections map[string]map[*trackedConn]struct{} 里。closeOnce 保证重复 Close 只生效一次。
CloseIP:封禁触发时主动掐断已建立连接
这是解决 keep-alive 问题的关键:
func (l *FilteringListener) CloseIP(ip string) int {
key := normalizedIPString(ip)
l.connectionsMu.Lock()
set := l.connections[key]
connections := make([]*trackedConn, 0, len(set))
for conn := range set {
connections = append(connections, conn)
}
l.connectionsMu.Unlock()
for _, conn := range connections {
_ = conn.Close() // ← 主动关闭该IP的所有已建立连接
}
return len(connections)
}当 WAF 在请求处理中发现某 IP 触发封禁(比如无效路径超阈值),除了写入封禁名单,还会调用 listener.CloseIP(ip)。这一下就把该 IP 当前所有活跃的 keep-alive/TLS 连接全部掐断。攻击者无法靠复用旧连接绕过新封禁——连接直接没了,下次得重新建连,而新建连会在 Accept 阶段被拒。
更改http.Server的监听方式
cmd/gopherink/main.go 的改动很小,但效果决定性:
// 改动前
return serveHTTPServer(runtimeContext, server, cfg)
// server.ListenAndServe() / server.ListenAndServeTLS(...)
// 改动后
l, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return err
}
if app.WAF != nil {
filtered := connfilter.New(l, app.WAF.Blacklist())
app.WAF.AttachConnectionFilter(filtered)
l = filtered
}
return serveHTTPServer(runtimeContext, server, cfg, l)
// server.Serve(l) / server.ServeTLS(l, ...)关键变化:不再用 server.ListenAndServe()(它内部自己 net.Listen + Serve,无法插入过滤层),而是自己 net.Listen,套上 FilteringListener,再 server.Serve(l)。
对 TLS 裸部署同样生效:server.ServeTLS(l, cert, key) 在过滤后的 listener 上跑 TLS 握手。黑名单 IP 的连接在 Accept 阶段就被 Close,连 TLS ClientHello 都发不出去。
黑名单接口:WAF侧的实现
core/handlers/waf.go实现了IPBlacklist接口,把WAF的封禁状态暴露给Listener:
type ipBlacklist struct {
waf *wafManager
snapshot atomic.Pointer[blacklistSnapshot] // 原子快照,无锁读
listener atomic.Pointer[connfilter.FilteringListener]
}
func (b *ipBlacklist) check(ip net.IP, now time.Time, recordHit bool) connfilter.HitResult {
snapshot := b.snapshot.Load()
if snapshot == nil || !snapshot.Active {
return connfilter.HitResult{} // 非裸部署模式,不启用连接层过滤
}
// 1. 静态黑名单(后台配置的 IP/CIDR)
if snapshot.Static.contains(addr.Unmap()) {
return connfilter.HitResult{Blocked: true, Reason: "static"}
}
// 2. 动态封禁(WAF 运行时写入的 banIndex)
if v, ok := b.waf.banIndex.Load(key); ok {
ban := v.(*wafBan)
if ban.active(now) {
extended := false
if recordHit {
extended = ban.recordHit(...) // 复用请求层的原子续期状态
}
return connfilter.HitResult{Blocked: true, Extended: extended}
}
}
return connfilter.HitResult{}
}两个黑名单来源合并查询:
- 静态黑名单:后台
waf_static_blacklist配置的 IP/CIDR,用prefixMatcher做 exact map + CIDR 前缀匹配,持续生效。 - 动态封禁:WAF 运行时由无效路径、附件下载、插件规则写入的
banIndex sync.Map,有过期时间。
封禁触发时联动关闭已建立连接:
func (m *wafManager) activateBan(ip string, duration time.Duration, maxEntries int, now time.Time) bool {
// ...写入 banIndex...
m.banMu.Unlock()
// 关键:封禁生效后,立即关闭该IP的已建立连接
if snapshot := m.blacklist.snapshot.Load(); snapshot != nil && snapshot.Active {
if listener := m.blacklist.listener.Load(); listener != nil {
listener.CloseIP(ip)
}
}
return true
}activateBan在写入封禁后,如果当前是裸部署模式,就调用listener.CloseIP(ip)把该IP的活跃连接全部掐断。这是"封禁即时生效"的最后一环——不仅新连接进不来,旧连接也活不了。
裸部署和反向代理部署配置
这次改进同时重构了部署模式配置,从原来的"信任转发头开关 + 黑白名单"合并为两个清晰的模式:
type clientIPConfig struct {
Mode string // "bare" 或 "proxy"
IPRules string // 可信代理 IP/CIDR 列表
}连接层过滤是否启用,取决于一个关键判断:
snapshot := &blacklistSnapshot{
Active: cfg.Enabled && cfg.ClientIP.Mode == "bare", // ← 只有裸部署才启用
// ...
}为什么反向代理模式不能在连接层过滤?在proxy模式下,TCP 直接来源是 Nginx/Cloudflare 的回源IP,不是真实访客IP。如果在连接层按TCP来源封禁:
- 攻击者从 Cloudflare 后面打你,Cloudflare 回源时用的是 Cloudflare 节点 IP
- 你把 Cloudflare 节点 IP 封了 → 所有经过该节点的正常访客也连不上
- 这等于把自己从 CDN 后面踢出去
所以proxy模式下,连接层过滤关闭,只在HTTP请求层解析X-Forwarded-For/X-Real-IP还原真实访客IP后再拒绝。连接级的限流和丢弃交给前置代理做。
裸部署是小型Web应用最常见的部署方式——直接./gopherink -p 80跑在VPS上,没有Nginx。这种场景下:
- TCP 直接来源就是真实访客 IP
- 连接层过滤完全安全,不会误杀
- 之前只能靠 L7 返回 429,开销下不来
- 现在可以在 L4 直接断开,开销断崖式下降
连接层和请求层共享封禁状态
之前的代码实现了"活跃封禁续期":被封IP在封禁期间继续请求,达到阈值就延长封禁时长。这次改进让连接层的连接丢弃也参与续期计数:
// FilteringListener.Accept() 里调用的是 CheckAndRecordHit(会计数)
result := l.Blacklist.CheckAndRecordHit(ip, time.Now())// ipBlacklist.check 里,recordHit=true 时复用 wafBan.recordHit
if recordHit {
extended = ban.recordHit(wafConfig{
BanExtensionEnabled: snapshot.Extension,
BanExtensionWindow: snapshot.ExtensionWindow,
BanExtensionHits: snapshot.ExtensionHits,
}, now)
}wafBan.recordHit用atomic.Uint64的CAS循环更新命中计数。连接层和请求层调用的是同一个wafBan对象的同一个原子状态。攻击者在封禁期间持续新建连接,每次 Accept都会CheckAndRecordHit记一次命中,达到阈值同样触发续期——即使它连一个 HTTP 请求都没成功发出去。
总结
如此一来,就解决了单点CC导致程序网络连接数量过多而资源耗尽的问题。而且封禁时的断开连接效果,也做到了接近Nginx的性能消耗水平,虽然内存占用还是比Nginx高了不少。。。
这个程序完全是靠OpenAI的Codex(模型是ChatGPT-5.5/ChatGPT-5.6-Sol)和华为云的CodeArts代码智能体(模型是GLM5.1/GLM5.2)开发的,而对于L4网络层的性能问题,则是我自己在局域网搭建了测试环境测试出来的。在此之前,我让这两个Agent互相检查过代码,虽然揪出了一些问题,但是这个L4网络层连接造成严重性能问题导致WAF形同虚设这一点没有一个能揪出来。WAF一定要能防CC——然后单点CC就能让应用在网络层建立连接产生巨大的性能开销,导致拒绝服务:啊对对对。甚至ChatGPT建议我直接让这个CMS部署到Nginx后面,由Nginx处理网络层的事情,代码不用改——还是ChatGPT会偷懒啊!
看来真的是,AI写的代码真的不靠谱,最终还是得要实际测试才能确保这些AI写出来的代码没问题。
断断续续开发这个CMS搞了一个暑假,从初始阶段的功能残缺,BUG满天飞,到现在功能基本上完善,性能优化基本上完工,继续查漏补缺进行细节打磨,看着目前GopherInk已经从一个一堆问题的玩具,到现在可以正常使用的高性能CMS,感慨颇深。虽然我还没在GitHub上面给这个项目开Release,而且就算Release的话也大概率只放个0.5.0或者0.6.0的版本(因为我不敢保证完全没问题),但是这确实已经是一个相当完善的个人兴趣项目了:有插件系统,可以设置主题,有博客CMS该有的功能,甚至我还给他开发了一些常用插件。
后续我计划尝试把我的NekoEcho主题进行适配,前提是我还有时间和足够的Token,但是现在OpenAI又缩紧政策了,暑假期间没有5小时限制和Tibo随机重置用量的快乐时光也结束了,这件事情后续能不能做出来我也不知道了。
项目快完工了,我也要快开学了,暑假结束了,事已至此,先发一篇文章吧。