16.2 洪泛与连接耗尽防护
先说清楚这一节的边界:它只讲怎么保护自己的服务端不被耗尽,不涉及任何针对第三方目标的攻击手法或工具。 我们要实现的是「配额 + 限速 + 超时」三件套,验证方式是本机 loopback 上开几十条连接,看自己的接入层在超限时是否正确拒绝。
连接耗尽的可怕之处在于它几乎不花对手什么成本。一条 TCP 连接建立后,如果服务端为它分配了会话、缓冲区和 goroutine,那么对手只要「连上、然后什么都不做」,就能让服务端的资源被一点点吃掉。它不需要带宽、不需要漏洞,只需要数量。上一节我们让解析器对畸形字节安全;这一节要让它根本轮不到解析——先把不该来的连接挡在门外。
耗尽的三种打法
| 打法 | 对手做什么 | 服务端若不设防 |
|---|---|---|
| 连接数占满 | 开满 fd 的连接,全部保持空闲 | accept 循环被拖慢,正常玩家连不上 |
| 未登录堆积 | 大量连接停在「已握手、未登录」状态 | 未登录连接吃满内存与句柄 |
| 慢速攻击(slowloris) | 每几十秒才发一个字节,让连接永远不超时 | 连接永不回收,配额被长期占住 |
三种打法对应的防御是三组参数:每 IP 连接上限、未登录连接上限、以及握手与读写超时。前两组是「数量配额」,第三组是「时间配额」。数量配额决定「一个 IP 最多占多少」,时间配额决定「占住了但不动的东西多久被收回」。
注意「每 IP 上限」不能单独使用。对手若手里有一批 IP(或干脆用 IPv6 的一整段),按 IP 限的额度会被平摊掉,所以还必须有全局上限兜底;而「未登录上限」是专门针对第二种打法的——它比总连接上限更小,让「握手完成但迟迟不登录」的连接先被清出去。
配额与握手限速的实现
核心数据结构是一张 map[IP]*ipState,每个 IP 记三个数:当前连接数、其中未登录的数、以及握手令牌桶的剩余令牌。所有读写都在同一把互斥锁下完成,规模是「每连接一次 acquire/release」,锁竞争可以忽略。
下面这段是 L2,go test 会真起端口并真连:
// file: internal/guard/limit.go
// tier: L2
// verify: go test ./internal/guard/ -run 'TestGateEnforcesPerIPQuota|TestGateDropsSlowHandshake' -v
// Package guard 为 aegis 接入层提供连接配额、握手限速与读写超时兜底。
package guard
import (
"errors"
"net"
"sync"
"time"
)
// 拒绝原因。接入层据此决定回执文案与是否计入风控信号。
var (
ErrIPQuotaExceeded = errors.New("guard: per-IP connection quota exceeded")
ErrGlobalQuotaExceeded = errors.New("guard: global connection quota exceeded")
)
// Limits 是接入层的配额参数。零值字段由 New 补齐为默认值。
type Limits struct {
MaxConnsPerIP int // 单个 IP 的并发连接上限
MaxPendingPerIP int // 单 IP 未登录(未完成握手/登录)连接上限
MaxConnsGlobal int // 全局并发连接上限
HandshakeBurst int // 单 IP 握手令牌桶容量
HandshakeRefill time.Duration // 单 IP 补满一个握手令牌所需的间隔
HandshakeTimeout time.Duration // 从 accept 到握手完成的墙钟上限
ReadTimeout time.Duration // 单次读空闲上限(slowloris 兜底)
WriteTimeout time.Duration // 单次写上限
}
// Limiter 维护全局与每 IP 的连接计数,以及每 IP 的握手令牌桶。
type Limiter struct {
lim Limits
mu sync.Mutex
global int
perIP map[string]*ipState
}
type ipState struct {
conns int
pending int
tokens float64
last time.Time
}
// New 用给定参数构造 Limiter,未填字段取默认值。
func New(lim Limits) *Limiter {
if lim.MaxConnsPerIP <= 0 {
lim.MaxConnsPerIP = 64
}
if lim.MaxPendingPerIP <= 0 {
lim.MaxPendingPerIP = 8
}
if lim.MaxConnsGlobal <= 0 {
lim.MaxConnsGlobal = 10000
}
if lim.HandshakeBurst <= 0 {
lim.HandshakeBurst = 5
}
if lim.HandshakeRefill <= 0 {
lim.HandshakeRefill = 200 * time.Millisecond
}
return &Limiter{lim: lim, perIP: make(map[string]*ipState)}
}
// Ticket 是一条连接占用的配额凭据。Authed 在握手/登录成功后调用一次,
// 把该连接从「未登录」计数移出;Close 在连接关闭时调用一次,幂等。
type Ticket struct {
l *Limiter
ip string
st *ipState
authed bool
once sync.Once
}
// Acquire 尝试为来自 ip 的一条新连接占用配额。
func (l *Limiter) Acquire(ip string, now time.Time) (*Ticket, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.global >= l.lim.MaxConnsGlobal {
return nil, ErrGlobalQuotaExceeded
}
st := l.perIP[ip]
if st == nil {
st = &ipState{tokens: float64(l.lim.HandshakeBurst), last: now}
l.perIP[ip] = st
}
if st.conns >= l.lim.MaxConnsPerIP || st.pending >= l.lim.MaxPendingPerIP {
return nil, ErrIPQuotaExceeded
}
st.conns++
st.pending++
l.global++
return &Ticket{l: l, ip: ip, st: st}, nil
}
// Authed 把这条连接标记为已登录,释放它占用的「未登录」名额。
func (t *Ticket) Authed() {
t.l.mu.Lock()
if !t.authed {
t.authed = true
t.st.pending--
}
t.l.mu.Unlock()
}
// Close 归还配额。幂等:重复调用只生效一次。
func (t *Ticket) Close() {
t.once.Do(func() {
t.l.mu.Lock()
defer t.l.mu.Unlock()
t.st.conns--
t.l.global--
if !t.authed {
t.st.pending--
}
if t.st.conns == 0 && t.st.pending == 0 {
delete(t.l.perIP, t.ip)
}
})
}
// AllowHandshake 是每 IP 的握手令牌桶:接受一条新连接时消耗一个令牌,
// 令牌按 HandshakeRefill 的间隔补充到 HandshakeBurst 上限。返回 false
// 表示该 IP 握手太频繁,应当直接关闭连接、不做任何回执。
func (l *Limiter) AllowHandshake(ip string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
st := l.perIP[ip]
if st == nil {
st = &ipState{tokens: float64(l.lim.HandshakeBurst), last: now}
l.perIP[ip] = st
}
if elapsed := now.Sub(st.last); elapsed > 0 {
st.tokens += elapsed.Seconds() / l.lim.HandshakeRefill.Seconds()
if st.tokens > float64(l.lim.HandshakeBurst) {
st.tokens = float64(l.lim.HandshakeBurst)
}
st.last = now
}
if st.tokens < 1 {
return false
}
st.tokens--
return true
}
// Gate 把配额与超时套在一个 net.Listener 上:Accept 之后先查握手限速与配额,
// 再给连接设一个握手截止时间。任何一步失败都直接关闭,不发无谓的回执。
type Gate struct {
Lim *Limiter
OnConn func(c net.Conn, tk *Ticket) // 配额通过后交给上层处理
}
// Serve 接受连接直到 listener 关闭。
func (g *Gate) Serve(ln net.Listener) error {
for {
c, err := ln.Accept()
if err != nil {
return err
}
go g.handle(c)
}
}
func (g *Gate) handle(c net.Conn) {
defer c.Close()
host, _, err := net.SplitHostPort(c.RemoteAddr().String())
if err != nil {
return
}
now := time.Now()
if !g.Lim.AllowHandshake(host, now) {
return
}
tk, err := g.Lim.Acquire(host, now)
if err != nil {
return
}
defer tk.Close()
if d := g.Lim.lim.HandshakeTimeout; d > 0 {
_ = c.SetDeadline(now.Add(d))
}
if g.OnConn != nil {
g.OnConn(c, tk)
}
}
// SteadyTimeouts 给一条已完成握手的连接套上稳态的读写超时。slowloris 靠
// 「慢慢发字节」占住连接,所以读超时必须按「两次读之间的空闲」设置,
// 而不是整条连接的总时长;每收到一帧就由上层重新调用一次。
func (g *Gate) SteadyTimeouts(c net.Conn) {
if d := g.Lim.lim.ReadTimeout; d > 0 {
_ = c.SetReadDeadline(time.Now().Add(d))
}
if d := g.Lim.lim.WriteTimeout; d > 0 {
_ = c.SetWriteDeadline(time.Now().Add(d))
}
}
有三点设计意图值得单说。第一,拒绝时什么都不发。配额超限或握手限速命中,handle 直接 return,连接随即被 defer c.Close() 关闭。给对方回一个错误包只会把「拒绝」变成「放大器」——攻击者用极小的请求就能诱导服务端生成更大的响应。第二,Ticket 的 Authed 与 Close 都幂等,因为登录成功、读循环出错、看门狗超时可能并发触发关闭,重复归还配额会让计数变负。第三,pending 与 conns 分开记:conns 防的是总量,pending 防的是「连上但不登录」的堆积,后者阈值应显著更小。
真跑:本机 loopback 的两组断言
测试不 mock 网络:startGate 用 net.Listen("tcp", "127.0.0.1:0") 起真端口。第一组断言「每 IP 上限之外的第 N+1 条连接被立刻关闭」,第二组断言「握手期内一个字节都不发的连接会被超时断开」。下面这段是 L2:
// snippet: internal/guard/limit_test.go(节选,startGate 已略)
// TestGateEnforcesPerIPQuota 是 L2:真连本机端口,超出每 IP 上限的连接被直接关闭。
func TestGateEnforcesPerIPQuota(t *testing.T) {
const cap = 4
lim := New(Limits{MaxConnsPerIP: cap, MaxConnsGlobal: 1000, HandshakeBurst: 1000})
hold := make(chan struct{})
ln, addr := startGate(t, lim, func(c net.Conn, tk *Ticket) {
tk.Authed()
_, _ = c.Write([]byte{'A'}) // 写标记字节 = 这条连接被接纳
<-hold
})
defer ln.Close()
defer close(hold)
var accepted int
var kept []net.Conn
for i := 0; i < cap+3; i++ {
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatalf("dial %d: %v", i, err)
}
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 1)
if n, err := c.Read(buf); err == nil && n == 1 && buf[0] == 'A' {
accepted++
kept = append(kept, c) // 必须持有引用,否则 GC 会提前关掉它
continue
}
_ = c.Close() // 读到 EOF/错误 = 被拒
}
for _, c := range kept {
_ = c.Close()
}
if accepted != cap {
t.Fatalf("accepted = %d, want exactly %d", accepted, cap)
}
}
第二组断言(TestGateDropsSlowHandshake)同理:HandshakeTimeout=150ms,OnConn 里先 Read 一个字节,读不到就直接返回;测试从客户端侧连上后一个字节都不发,Read 应当返回错误,且耗时落在 150ms 到 160ms 之间。它验证的正是「连接被接纳了,但迟迟不推进握手」时,SetDeadline 会把它按时收回。
实测结论
以下是本机(Go 1.26,macOS arm64)实测,同机相对量级,绝对数值不可移植:
| 指标 | 实测值 | 说明 |
|---|---|---|
| 每 IP 上限 | 4 条接纳,第 5 条起被立即关闭 | 20 条 loopback 连接,无一条误放 |
| 全局上限 | 达阈值即返回 ErrGlobalQuotaExceeded | 纯计数测试,不碰网络 |
| 握手令牌桶 | 突发 3 个后拒绝,100ms 后恢复 1 个 | HandshakeBurst=3,HandshakeRefill=100ms |
| slowloris 超时 | 配置 150ms,实测 150–160ms | 静默客户端被断开 |
| 竞态检测 | go test -race 全绿 | 计数与令牌桶无数据竞争 |
一个必须写进部署手册的坑:macOS 的 kern.ipc.somaxconn 默认只有 128,本节的 loopback 自测因此刻意控制在几十条连接以内。真要做千连接级压测必须错峰连接(每连一条 sleep 一下),否则内核会直接 RST 掉一部分,测出来的数字是假的。防护逻辑的正确性用几十条连接就能验证,规模压测留给第 17 章的机器人客户端。
阅读导航:上一节:16.1 协议 fuzz 与畸形包 · 下一节:16.3 封包加密与签名 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。