Go HTTP服务防CC性能优化:把WAF封禁从L7前置到L4封面图

Go HTTP服务防CC性能优化:把WAF封禁从L7前置到L4

这段时间我在开发和完善我的玩具项目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 的请求,在到达封禁检查之前,已经走完了:

  1. TCP 三次握手——内核分配 socket buffer、占用 accept 队列
  2. Accept() 返回 + goroutine 创建——Go runtime 为这个连接分配一个 goroutine(初始栈 2~8KB)
  3. TLS 握手(如果启用了 HTTPS)——分配 TLS 状态机、读 ClientHello、做非对称运算
  4. HTTP 请求头解析——分配 bufio.Reader、解析请求行和所有头
  5. 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
    }
}

几个值得注意的细节:

  1. conn.Close()continue:被拒连接不会返回给 http.Server,循环继续 Accept 下一个。对 http.Server 来说,被拒连接就像从来没存在过。
  2. 两次检查防竞态:第一次 CheckAndRecordHit(会记录命中用于续期)和 register 之间,可能有其他 goroutine 刚好把这个 IP 写入封禁。注册后再做一次 Check(不计数),如果此时被封就关闭。这避免了"刚注册的连接绕过新封禁"的窗口,又不会重复计数。
  3. 返回 trackedConn 而非原始 conntrackedConn 包装了 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.recordHitatomic.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随机重置用量的快乐时光也结束了,这件事情后续能不能做出来我也不知道了。


项目快完工了,我也要快开学了,暑假结束了,事已至此,先发一篇文章吧。

评论区 (0)