15.1 权威边界与信任模型
第 14 章讲完了延迟补偿与高延迟公平性,但整套裁决有一个前提:服务端相信自己在裁决的东西。反外挂不是「在客户端装一个检测模块」,而是「服务端从一开始就不把客户端的断言当成事实」。一个玩家把客户端改成 3 倍移速,如果服务端只是照单全收地广播新坐标,那么传输层加密、心跳、延迟补偿都救不了他——他根本没撒谎,他只是「报告了一个服务端没质疑的位置」。
所以这一节不写检测规则,先写边界:一条连接上,哪些话客户端可以说,哪些话只有服务端能说。边界画错了,后面两节的校验规则、信号采集、限速都会建在流沙上。
本节的目标是给 aegis 一个可执行的信任契约表(trust contract)。它不是文档,是代码:每条命令的每个字段都标注信任等级,服务端在反序列化之后、业务处理之前,机械地检查「客户端有没有发来它不该发的字段」。这样边界就不依赖开发者的记忆。
与站内实践文章的分工
站内 game/server/practice/ 专题里有几篇与权威、反作弊直接相关的架构文。它们负责讲「方案怎么选、边界怎么划、线上怎么踩坑」,本节负责「把边界写成一张能跑的表」。分工如下:
| 站内实践文章 | 它负责的方案层 | 本节负责的实现层 |
|---|---|---|
游戏服务器如何做权威移动校验 | 移动校验放在哪一层、与预测/回滚如何协作 | 只定义「提议 vs 裁决」的字段级信任契约 |
在线游戏后端反作弊信号设计 | 反作弊信号有哪些、如何分级处置 | 只提供信号得以成立的前提:字段可信 |
游戏服务器权威状态架构设计 | 权威状态整体架构与一致性取舍 | 只落地「谁有权写状态」这一条最小规则 |
这三篇是「为什么这么划」,本节是「划完怎么用代码守住」。
权威边界:客户端提议,服务端裁决
把一条连接上流动的信息分成两类:
- 提议(proposal):客户端说「我想做某事」。比如「我要移动到 (x,y,z)」「我要用技能 1001」「我要买道具 A」。提议是意图,不是事实;服务端可以拒绝、可以改、可以忽略。
- 裁决(adjudication):服务端说「发生了什么」。位置、血量、冷却到期时间、金币余额、掉落结果。这些只能由服务端写,客户端连「建议值」都不该出现在协议里。
边界画在哪,取决于两个问题:
- 它会不会影响别人? 位置影响别人(看错我在哪就会误判命中),血量影响别人,经济影响全服。影响别人的,必须服务端裁决。
- 它会不会影响数值或经济? 掉落、经验、金币、排名——只要能被折算成稀缺资源,就必须服务端裁决。
两个都是「否」的,可以留在客户端:镜头旋转、表情动作、UI 开关、本地音效。它们不影响任何人,也不产生资源,服务端没必要为之花 CPU。
客户端可提议 服务端必须裁决
─────────────── ──────────────────
目的地 to 最终位置、朝向
技能 id 冷却到期时间、伤害数值
物品 id 背包内容、金币余额
攻击目标 命中判定、暴击结果
聊天文本 过滤结果、发送频率
注意最后一行:聊天文本是提议,但「发送频率」是裁决。玩家可以提议任意内容,但「一秒钟能发几条」由服务端定——这一条留到 15.3 的令牌桶。
信任模型:可校验 ≠ 可采信
边界有了,接下来是信任模型。核心一句话:客户端的输入一律不可信,可以校验,但不可采信。「校验」和「采信」是两回事:
- 校验(validate):检查客户端说的值是否在允许范围内,通过就接受。
- 采信(trust):直接拿客户端的值当权威值用,不检查。
最危险的不是「没校验」,而是「把不该采信的字段当成了权威」。典型事故:客户端发来 {to, speed, hp},服务端用 speed 去算移动合法性——而 speed 本该由服务端从角色属性算出来。攻击者把 speed 改成 9999,校验「通过」了,因为它压根没被校验,直接被采信。
所以信任模型必须落到字段级别。下面这段是 L1,go test 可以直接跑:
// file: internal/authority/boundary.go
// tier: L1
// verify: go test ./internal/authority/ -run 'TestTrust'
package authority
import (
"fmt"
"sort"
)
// Trust classifies every field a client can put on the wire. The whole point of
// making it a type is that the classification stops being a thing a developer
// has to remember: it lives next to the command definition and the adjudicator
// reads it mechanically.
type Trust uint8
const (
// Untrusted is a field the server never believes on its own. It is read,
// used as a hint, and then recomputed from server state.
Untrusted Trust = iota
// Advisory is a field the server may accept, but only after the value has
// passed a server-side validator. "Client says it moved here" is advisory.
Advisory
// Derived is a server-owned field. The client never sends it, and a frame
// that carries one is a protocol violation regardless of its value.
Derived
)
// String renders a Trust for logs.
func (t Trust) String() string {
switch t {
case Untrusted:
return "untrusted"
case Advisory:
return "advisory"
case Derived:
return "derived"
default:
return "unknown"
}
}
// FieldSpec is the trust contract for one field of one command.
type FieldSpec struct {
Name string
Trust Trust
}
// CommandSpec is the trust contract for a single command kind. Registering the
// contract is how the boundary gets drawn: whatever is not listed is not
// accepted, and whatever is Derived may not arrive from a client.
type CommandSpec struct {
Kind string
Fields []FieldSpec
}
// Violation is one breach of the trust contract found in an inbound message.
type Violation struct {
Command string
Field string
Reason string
}
// Error implements error so a Violation can be returned directly.
func (v Violation) Error() string {
return fmt.Sprintf("command %s field %s: %s", v.Command, v.Field, v.Reason)
}
// Registry holds the trust contract for every command the server accepts. It is
// deliberately small: the boundary is a table, not a code path.
type Registry struct {
specs map[string]CommandSpec
}
// NewRegistry returns an empty trust registry.
func NewRegistry() *Registry {
return &Registry{specs: make(map[string]CommandSpec)}
}
// Register installs a command's trust contract. Duplicate command kinds and
// duplicate field names are rejected so the table cannot silently drift.
func (r *Registry) Register(spec CommandSpec) error {
if spec.Kind == "" {
return fmt.Errorf("authority: empty command kind")
}
if _, dup := r.specs[spec.Kind]; dup {
return fmt.Errorf("authority: duplicate command kind %q", spec.Kind)
}
seen := make(map[string]bool, len(spec.Fields))
for _, f := range spec.Fields {
if seen[f.Name] {
return fmt.Errorf("authority: duplicate field %q in %q", f.Name, spec.Kind)
}
seen[f.Name] = true
}
r.specs[spec.Kind] = spec
return nil
}
// Check reports every field the client must not have sent. The rule is narrow
// and absolute: Advisory and Untrusted fields are allowed through to the
// validator, but a Derived field arriving from a client is a violation no
// matter what value it carries. Output is sorted so tests and logs are stable.
func (r *Registry) Check(kind string, sent []string) []Violation {
spec, ok := r.specs[kind]
if !ok {
return []Violation{{Command: kind, Reason: "unknown command"}}
}
class := make(map[string]Trust, len(spec.Fields))
for _, f := range spec.Fields {
class[f.Name] = f.Trust
}
var out []Violation
for _, name := range sent {
t, known := class[name]
switch {
case !known:
out = append(out, Violation{Command: kind, Field: name, Reason: "field not in contract"})
case t == Derived:
out = append(out, Violation{Command: kind, Field: name, Reason: "client sent a server-derived field"})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Field != out[j].Field {
return out[i].Field < out[j].Field
}
return out[i].Reason < out[j].Reason
})
return out
}
三个信任等级对应三种处置:
Untrusted(不可信):读进来当提示,随后用服务端状态重算。elapsed_ms属于此类——客户端说「过了 100ms」,服务端不信,用自己的 tick 间隔。Advisory(可采信但须先校验):可以接受,但必须先过校验器。to(目的地)属于此类。Derived(服务端派生):客户端永远不该发。speed、hp属于此类。
Registry.Check 的规则很窄也很绝对:Advisory 和 Untrusted 放行给校验器,Derived 一出现就是违规,不管它带的是什么值。这个设计的好处是「零假阳性」——诚实的客户端永远不会发 Derived 字段,所以任何一次命中都是真信号,可以直接进 15.3 的嫌疑分。
测试断言了这一点:
// snippet: internal/authority/boundary_test.go(节选)
// 诚实客户端只发它被允许发的字段,不该被误报。
if v := r.Check("scene.move", []string{"to", "elapsed_ms"}); len(v) != 0 {
t.Fatalf("honest client flagged: %v", v)
}
// 篡改客户端注入服务端自有字段,两个都必须被抓到。
got := r.Check("scene.move", []string{"to", "speed", "hp"})
if len(got) != 2 {
t.Fatalf("got %d violations, want 2: %v", len(got), got)
}
为什么「全搬服务端」是错的方向
画完边界,一个自然的反应是「那我把所有东西都搬到服务端,不就没边界了」。这是最容易走的弯路,值得单独说清楚它错在哪。
第一,成本和延迟不划算。 镜头旋转、本地预测的位移、UI 状态,如果每个都走一次服务端裁决,一帧就是几十条请求;一个场景几百人,光这些无意义的往返就能把网关压垮。真正需要裁决的是「影响别人的那部分」,不是「所有部分」。
第二,也是更关键的:全搬服务端并不能消灭外挂,只会把外挂推向更底层。 把位置搬上服务端后,攻击者不会放弃,他会去攻击「输入」——伪造大量合法但无意义的移动来干扰 AOI,或者攻击「时序」——卡在冷却判定的边界上疯狂触发。服务器权威不是「外挂消失」,而是「外挂能影响的范围被压到服务端愿意为之付出代价的那一档」。
第三,边界本身是有弹性的。 竞技向的 MOBA 会把命中判定全放服务端,休闲向的钓鱼小游戏可以把拉杆动画全放客户端。边界画在哪取决于玩法、成本和作弊的收益,不是一个非黑即白的教条。
一句话:权威边界是「把有限的裁决预算花在影响别人和影响数值的地方」,不是「把一切搬上服务端」。
本节小结
- 权威边界回答「谁有权写状态」:客户端提议,服务端裁决。
- 边界按两条标准划:是否影响别人、是否影响数值或经济。
- 信任模型落到字段级:
Untrusted重算、Advisory先校验、Derived一出现即违规。 - 信任契约表是代码不是文档,
Registry.Check让「客户端发来服务端自有字段」变成可机械检测的违规。 - 「全搬服务端」不是答案——它贵、慢,且把外挂推向输入与时序这些更隐蔽的面。
下一节把这个边界用在最频繁、也最容易被滥用的两条路径上:移动和战斗。
阅读导航:上一节:目录 · 下一节:15.2 移动与战斗合法性校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。