一、GM 系统的第一道门是权限
GM 命令看起来只是运营工具,实际却直接改写玩家资产、封禁账号、发放道具,一次误操作就可能变成一次线上事故。因此 GM 系统的设计顺序应当是先权限、再命令、最后才是界面,而不是先做一个能点的后台再补权限。
一个常见的反例是权限只在前端判断:按钮灰掉就认为安全,接口却可以被任意构造的请求直接调用。前端权限只是交互提示,真正的授权必须发生在服务端每一次执行之前,并且与命令、目标、参数一起进入审计。
1.1 三条硬约束
- 最小权限:每个角色只拿到完成工作所需的最小权限集合,查询与写操作分开授权。
- 默认拒绝:没有显式授予的权限一律拒绝,未知命令、未知角色、空身份都走拒绝分支。
- 授权可回收:权限变更有明确入口,离职、转岗或临时活动结束后能立即收回。
| 原则 | 推荐做法 | 省略后的典型后果 |
|---|---|---|
| 最小权限 | 查询、单服操作、全服操作分成不同权限 | 客服账号可以误发全服邮件 |
| 默认拒绝 | 先检查权限,再解析参数和执行 | 新命令上线时忘记加校验直接裸奔 |
| 授权可回收 | 角色绑定有到期时间,支持一键摘除 | 临时账号长期保留高危权限 |
二、RBAC:角色、权限、操作
RBAC 把授权拆成三层:角色绑定权限,身份绑定角色,命令声明自己需要的权限。执行时只回答一个问题:当前身份通过角色并集,是否拥有该命令要求的权限。这样新增命令只需要声明权限名,新增人员只需要调整角色,不需要在业务代码里堆 if user == "admin"。
| 对象 | 示例 | 存储位置 | 注意事项 |
|---|---|---|---|
| 身份 | 运营账号、客服账号、值班账号 | 账号系统 | 不使用共享账号,离职即禁用 |
| 角色 | 客服、活动运营、数值策划、管理员 | 权限服务 | 角色数量保持可读,避免一人一角色 |
| 权限 | player.ban、mail.send、server.notice | 权限表 | 权限名与命令名解耦,便于迁移 |
2.1 临时授权与双人复核
临时授权应当带到期时间,并由系统自动回收,而不是靠人工记得删除。双人复核则把高危操作拆成“发起”和“批准”两个动作:发起人提交目标与参数,批准人看到完整上下文后确认,执行时使用一次性的批准令牌。
- 临时授权记录授权人、被授权人、权限、生效时间与到期时间。
- 高危操作要求不同账号发起与批准,禁止自批自审。
下面的示例代码聚焦命令注册、权限校验、危险命令确认和审计链,没有实现完整的审批流。生产系统可以在 Execute 之前增加审批状态检查,把批准令牌作为 Confirm 的一部分传入。
三、命令系统的执行流水线
一条 GM 命令从请求到回执应当经过固定阶段,每个阶段都有明确的失败分支。流水线化的好处是审计字段完整、错误可分类、测试可以逐段覆盖,而不是把解析、校验和执行揉在一个大函数里。
| 阶段 | 检查内容 | 失败处理 | 是否写审计 |
|---|---|---|---|
| 解析 | 命令名、目标、参数是否可解析 | 返回参数错误 | 是 |
| 查找 | 命令是否已注册 | 返回未知命令 | 是 |
| 鉴权 | 身份角色是否拥有命令权限 | 返回拒绝 | 是 |
| 校验 | 必填参数、范围、跨字段约束 | 返回参数错误 | 是 |
| 确认 | 高危命令是否带正确确认串 | 返回需要确认 | 是 |
| 执行 | 调用 Handler,处理返回与异常 | 返回执行错误 | 是 |
参数校验必须在执行前完成。封禁命令如果只检查“有没有 reason”,却不检查时长范围,就可能出现封禁一万年的误操作;发邮件命令如果不限制附件数量,就可能把单次请求放大成一次存储事故。
3.1 危险等级与二次确认
| 等级 | 典型命令 | 要求 |
|---|---|---|
| 低 | 查询在线人数、查看玩家背包 | 权限校验即可 |
| 中 | 单玩家封禁、单玩家补偿 | 权限校验,记录完整参数 |
| 高 | 全服邮件、批量封禁、经济参数调整 | 权限校验、二次确认、双人复核 |
二次确认不能只是一个“确定吗”的前端弹窗。确认串应当与命令和目标绑定,最好由服务端生成、短有效期、一次性使用;否则调用方可以预先拼出确认串,批量调用高危命令。示例用命令名和目标的摘要作为确认串,目的是让测试可重复,生产环境应替换为随机令牌。
四、审计:谁在何时对谁做了什么
审计日志至少要回答五个问题:谁发起、什么时候、对谁、做了什么、结果如何。字段缺失会让事故复盘变成猜谜,尤其是“参数是什么”和“为什么被拒绝”这两类信息,往往比最终成功与否更重要。
| 字段 | 含义 | 示例 |
|---|---|---|
| Seq | 单调递增序号 | 1024 |
| At | 服务端接收时间 | 2026-11-04T10:36:40+08:00 |
| Operator | 操作者身份 | gm-zhangsan |
| Command | 命令名 | player.ban |
| Target | 操作目标 | player-8821 |
| Args | 完整参数副本 | reason=cheat |
| Confirmed | 是否完成二次确认 | true |
| Outcome | 成功或失败 | ok / error |
| Message | 输出或错误原因 | banned player-8821 |
不可篡改不等于“只写不删”。示例给每条记录计算包含前一条哈希的 SHA-256 链,任何中间记录被修改或删除,后续校验都会失败。真实系统还应把审计同步到独立日志平台或对象存储,并设置只追加权限和保留周期。
4.1 失败场景:权限只在前端校验
某后台把“全服邮件”按钮按角色隐藏,接口却没有做服务端鉴权。一次内部压测脚本误调了接口,使用普通客服账号向全服发送了带附件的邮件,等运营发现时邮件已经进入玩家邮箱。
| 时间 | 动作 | 后果 |
|---|---|---|
| T+0s | 脚本携带客服账号调用发送接口 | 服务端未校验权限,请求进入业务层 |
| T+2s | 邮件任务开始批量投递 | 全服玩家收到错误附件 |
| T+30s | 运营发现异常并停任务 | 已投递部分无法撤回 |
| T+1h | 复盘发现接口无鉴权、无审计 | 无法定位调用来源 |
正确的做法是服务端对每个命令做权限校验,高危命令要求二次确认,批量投递进入审批与限额流程,并把请求来源、参数和结果写入审计。前端隐藏按钮只能减少误点,不能承担安全职责。
五、可运行实现:gm.go
示例把命令注册、角色授权、参数校验、二次确认和审计链放在一个包里。Execute 的所有返回路径都会调用 finish,因此成功、越权、缺确认、参数错误和未知命令都会留下记录;审计记录返回副本,调用方修改不会破坏内部链。
// file: ch16/gm/gm.go
// tier: L1
// verify: go test ./ch16/gm/
package gm
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
)
var (
ErrUnknownCommand = errors.New("gm: unknown command")
ErrPermissionDenied = errors.New("gm: permission denied")
ErrConfirmationRequired = errors.New("gm: confirmation required")
ErrInvalidArgument = errors.New("gm: invalid argument")
)
type RiskLevel int
const (
RiskLow RiskLevel = iota
RiskMedium
RiskHigh
)
type Principal struct {
ID string
Roles []string
}
type Request struct {
Operator *Principal
Command string
Target string
Args map[string]string
Confirm string
}
type Command struct {
Name string
Permission string
Risk RiskLevel
Required []string
Handler func(Request) (string, error)
}
type AuditRecord struct {
Seq uint64
At time.Time
Operator string
Command string
Target string
Args map[string]string
Confirmed bool
Outcome string
Message string
PrevHash string
Hash string
}
type Registry struct {
mu sync.RWMutex
commands map[string]*Command
grants map[string]map[string]struct{}
audit []AuditRecord
now func() time.Time
}
func NewRegistry() *Registry { return NewRegistryWithClock(time.Now) }
func NewRegistryWithClock(now func() time.Time) *Registry {
if now == nil {
now = time.Now
}
return &Registry{
commands: map[string]*Command{},
grants: map[string]map[string]struct{}{},
now: now,
}
}
func (r *Registry) Register(cmd Command) error {
if cmd.Name == "" || cmd.Permission == "" || cmd.Handler == nil {
return fmt.Errorf("gm: invalid command %q", cmd.Name)
}
cmd.Required = append([]string(nil), cmd.Required...)
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.commands[cmd.Name]; ok {
return fmt.Errorf("gm: command %q already registered", cmd.Name)
}
r.commands[cmd.Name] = &cmd
return nil
}
func (r *Registry) Grant(role string, perms ...string) {
r.mu.Lock()
defer r.mu.Unlock()
set := r.grants[role]
if set == nil {
set = map[string]struct{}{}
r.grants[role] = set
}
for _, perm := range perms {
set[perm] = struct{}{}
}
}
func ConfirmationToken(command, target string) string {
sum := sha256.Sum256([]byte(command + "\x00" + target))
return "confirm-" + hex.EncodeToString(sum[:8])
}
func (r *Registry) Execute(req Request) (string, error) {
started := r.now()
r.mu.RLock()
cmd, known := r.commands[req.Command]
allowed := known && req.Operator != nil
if allowed {
allowed = false
for _, role := range req.Operator.Roles {
if _, ok := r.grants[role][cmd.Permission]; ok {
allowed = true
break
}
}
}
r.mu.RUnlock()
if !known {
return r.finish(req, nil, started, "", ErrUnknownCommand)
}
if !allowed {
return r.finish(req, cmd, started, "", ErrPermissionDenied)
}
for _, name := range cmd.Required {
if strings.TrimSpace(req.Args[name]) == "" {
return r.finish(req, cmd, started, "",
fmt.Errorf("%w: missing %q", ErrInvalidArgument, name))
}
}
if cmd.Risk >= RiskHigh && req.Confirm != ConfirmationToken(cmd.Name, req.Target) {
return r.finish(req, cmd, started, "", ErrConfirmationRequired)
}
out, err := cmd.Handler(req)
return r.finish(req, cmd, started, out, err)
}
func (r *Registry) finish(req Request, cmd *Command, at time.Time, output string, err error) (string, error) {
rec := AuditRecord{
At: at,
Command: req.Command,
Target: req.Target,
Args: cloneArgs(req.Args),
Outcome: "ok",
Message: output,
}
if req.Operator != nil {
rec.Operator = req.Operator.ID
rec.Confirmed = cmd != nil && cmd.Risk >= RiskHigh &&
req.Confirm == ConfirmationToken(cmd.Name, req.Target)
}
if err != nil {
rec.Outcome = "error"
rec.Message = err.Error()
}
r.mu.Lock()
rec.Seq = uint64(len(r.audit) + 1)
if n := len(r.audit); n > 0 {
rec.PrevHash = r.audit[n-1].Hash
}
rec.Hash = hashRecord(rec)
r.audit = append(r.audit, rec)
r.mu.Unlock()
return output, err
}
func hashRecord(rec AuditRecord) string {
rec.Hash = ""
body, _ := json.Marshal(rec)
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
func (r *Registry) AuditLog() []AuditRecord {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]AuditRecord, len(r.audit))
for i, rec := range r.audit {
rec.Args = cloneArgs(rec.Args)
out[i] = rec
}
return out
}
func (r *Registry) AuditByOperator(id string) []AuditRecord {
out := make([]AuditRecord, 0)
for _, rec := range r.AuditLog() {
if rec.Operator == id {
out = append(out, rec)
}
}
return out
}
func (r *Registry) VerifyAudit() error {
r.mu.RLock()
defer r.mu.RUnlock()
prev := ""
for i, rec := range r.audit {
if rec.Seq != uint64(i+1) || rec.PrevHash != prev || rec.Hash != hashRecord(rec) {
return fmt.Errorf("gm: audit chain broken at record %d", rec.Seq)
}
prev = rec.Hash
}
return nil
}
func cloneArgs(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
测试覆盖越权拒绝、缺确认拒绝、参数缺失、未知命令、审计字段完整性、哈希链篡改检测以及审计副本隔离,共 8 个测试。运行方式:
cd .local/checks/gs-practice-lab
gofmt -l ch16/gm
go vet ./ch16/gm/
go test ./ch16/gm/ -count=1
5.1 实现边界
示例的确认串是确定性摘要,只适合演示;生产环境应换成服务端生成的一次性随机令牌,并校验有效期、审批单号和使用次数。审计链只能发现单进程内记录的静默修改,如果整份日志可被重写,仍需要独立只追加存储或签名服务兜底。内存审计也不适合长期保留,应异步写入独立存储,并监控写入失败、链校验失败和积压;高危命令在审计写失败时应默认拒绝执行。
六、和 16.2 的衔接
权限和审计解决“谁能改、改了什么”,但命令背后的参数仍然散落在配置文件和代码常量里。下一篇 /game-server-in-action-16-2/ 会把配置抽成带版本号的快照,讨论热更、回滚、订阅通知和上线前校验。
小结
GM 系统的安全边界在服务端:RBAC 决定谁能执行,命令流水线决定按什么顺序检查,二次确认拦住高危误操作,审计链回答事后追责。把这些能力做成统一入口,而不是散落在每个命令里,才能让新增命令默认继承安全约束。
常见错误与边界
- 只在后台页面隐藏按钮,接口没有服务端鉴权。
- 审计只记录成功操作,越权和拒绝反而没有留下证据。
- 审计记录返回内部切片或 map,调用方修改后污染哈希链。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。