Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DedicatedClientGuard · TShock 服务端专用包准入守卫

服务器只放行握手时携带专属令牌的定制客户端,原版 Terraria 客户端在握手阶段被拒绝。

TShock Terraria .NET Tests License


一、背景

这是一个 TShock 服务端插件(TerrariaPlugin 子类),在握手阶段做客户端准入。握手时携带专属令牌的「服务端专用包」客户端放行,原版 Terraria 客户端一律被提醒并踢出。判定只看客户端自带的版本前缀,与玩家账号无关,实际效果等同于一份客户端指纹白名单。

典型用途

场景 说明
私有服 / 小圈子服 只允许自家人用定制包进服,挡住随机扫端口的原版玩家
分流与公告 拒绝时下发自定义提示,可直接引导玩家去下载专用包
合规兜底 与账号白名单、设备绑定叠加,形成多层准入

配套的客户端侧(把令牌注入 APK 的补丁工具与重签名流程)不在本仓库范围内,见文末「配套组件」。


二、工作原理

2.1 握手包携带的信息

Terraria 客户端连接服务器时发的第一个包是 ConnectRequest(MessageID = 1)。经源码核实,它的 payload 只有一个 .NET BinaryWriter 字符串:

原版客户端      "Terraria" + <release号>      例:"Terraria326"
专用包客户端    "<令牌>"    + <release号>      例:"TSC0DE1A326"

服务端在 MessageBuffer.GetData 的 case 1 里用 reader.ReadString() 读出该串,与本地 "Terraria" + Main.curRelease 做全等比较,不等则下发 MessageID.Disconnect。

包体里没有 UUID、玩家名或任何自由格式字段,所以「客户端身份」唯一可用的载体就是这串版本前缀。

2.2 令牌的植入方式

移动版是 Unity IL2CPP 构建,C# 代码编译进 libil2cpp.so,字符串常量存放在 assets/bin/Data/Managed/Metadata/global-metadata.dat 的字符串字面量表中。

补丁工具按内容定位到唯一一个内容为 "Terraria" 的条目,等长替换为专属令牌:

元数据版本      : 31
字面量条目总数  : 18819
命中条目        : #12763  (dataIndex=0x57014)
原值            : b'Terraria'
新值            : b'TSC0DE1A'

必须等长替换,是因为 IL2CPP 的 stringLiteralData 是一个扁平字节串,各条目靠 (length, dataIndex) 定位。等长替换意味着 length 字段、blob 总长度、以及后续所有条目的 dataIndex 全部不变,结构零风险。因此令牌长度必须恰好 8 字节(与 "Terraria" 等长)。

2.3 拦截流程

插件在 ServerApi.Hooks.NetGetData 上以 int.MinValue 优先级注册,确保早于 TShock 自身的处理器拿到原始包:

HookManager.InvokeNetGetData 内部顺序:
  1. InvokeServerConnect  →  TShock.OnConnect 在此创建 TSPlayer
  2. NetGetData 集合触发  →  本插件在此(int.MinValue 排最前)
  3. args.Handled == true →  OTAPI 直接 return,跳过 vanilla 处理

判定与处置都在 DedicatedClientGuardPlugin.OnNetGetData 里:

ConnectRequest(id=1) 到达
   │  Enabled=false → 完全不干预
   ▼
读取版本串(起点 = args.Index,自行解析 7-bit 变长长度前缀)
   ├─ 解析异常 / 空串   → REJECT-PARSEFAIL / REJECT-EMPTY   踢出
   ├─ 不可解析          → REJECT-UNPARSEABLE                踢出
   ├─ 令牌 = Terraria   → REJECT-VANILLA                    踢出「无专用包标识」
   ├─ 令牌不在白名单    → REJECT-TOKEN                      踢出
   ├─ release 不匹配    → REJECT-RELEASE                    踢出(仅 ExactWithRelease)
   └─ 全部通过          → ACCEPT                            接管握手放行

两种模式下判定的处置方式不同,解析与审计是同一套:

DryRun 行为
false(默认,强制) 命中拒绝则下发自定义提示并踢出;命中放行则接管握手
true(干跑观察) 仍然完整解析并写审计日志,但一律不拦截、不接管(放行交回原版逻辑)

干跑模式的用处是上线前统计「各客户端实际上报的版本串」,用来确认令牌是否写对。 注意干跑不接管握手,因此此时专用包反而会被原版的版本校验踢掉,这是预期行为。

2.4 放行为何要接管握手

专用包上报的是 TSC0DE1A326,原版的版本检查必然失败并把玩家踢掉。所以插件要自己完成原版本该做的事(AdoptHandshake):

Netplay.Clients[who].State = 1;                                   // 推进状态 0 → 1
NetMessage.SendData((int)PacketTypes.ContinueConnecting, who, -1, // 手动下发 msg 3
                    NetworkText.FromLiteral(""), who);
args.Handled = true;                                              // 阻止原版的版本检查

后续流程(ClientUUID → PlayerInfo → ContinueConnecting2 …)完全交回原版与 TShock 处理。

服务器设置了 Terraria 密码时改发 PasswordRequired(msg 37)。 这与社区插件 SkipVersionCheck 采用的机制相同,区别在于它放宽版本检查,本插件收紧准入。

拒绝时同样要 Handled = true,否则原版会接着发它自己的 LegacyMultiplayer.4("You are not using the same version as this server."),玩家会看到两条互相矛盾的提示。


三、编译与部署

3.1 编译

# 1) 自备 TShock 参考程序集(见 refs/README.md,勿入库)
#    下载 TShock 6.2.1 (Terraria 1.4.5.8) 发行包,解出:
#      refs/tshock-6.2.1/lib/{TShockAPI,TerrariaServer,OTAPI,OTAPI.Runtime,ModFramework,HttpServer}.dll

# 2) 编译
dotnet build plugin/DedicatedClientGuard/DedicatedClientGuard.csproj -c Release
#    产出: plugin/DedicatedClientGuard/bin/Release/DedicatedClientGuard.dll

3.2 部署

  1. 停止 TShock 服务器。

  2. 把 DedicatedClientGuard.dll 复制到 <服务器根>/ServerPlugins/DedicatedClientGuard.dll。

  3. 启动服务器。首次启动会自动生成 ServerPlugins/DedicatedClientGuard/config.json(默认 Enabled: true) 并打印自检信息:

    [DedicatedClientGuard] 专用包准入守卫已加载。Enabled=True,Mode=Prefix,DryRun=False,令牌=[TSC0DE1A],release白名单=[]
    [DedicatedClientGuard] ========== 专用包准入自检 ==========
      服务端 release 号   : 326
      原版客户端将上报    : "Terraria326"(将被拒绝)
      专用包将上报        : "TSC0DE1A<release>"
      准入模式            : Prefix
      强制校验            : 是
      release 白名单      : (不限)
      ==================================
    

首次部署建议先把 DryRun 设为 true 观察一轮,确认日志里专用包记录的确实是 ACCEPT ... raw="<你的令牌><release>" 之后,再切回强制校验。 配置错误会导致所有人都进不来。

3.3 验证

用哪种客户端连 期望结果
专用包 正常进入服务器;日志出现 ACCEPT ... token=<令牌>
原版客户端 看到你的自定义提示并被踢出;日志出现 REJECT-VANILLA

不想开游戏也可以直接验:按 Terraria 帧格式 ([uint16 小端 总长][uint8 包类型][payload],总长含自身 2 字节)向服务器 7777 端口发一个 ConnectRequest,看应答包类型即可,type=3 为放行(ContinueConnecting), type=2 为拒绝(Disconnect)。


四、配置项

路径:<服务器根>/tshock/ServerPlugins/DedicatedClientGuard/config.json (tshock 即 TShock.SavePath;文件不存在时按默认值自动生成)

{
  "Enabled": true,
  "AllowedTokens": [ "TSC0DE1A" ],
  "AllowedReleases": [],
  "KickMessage": "&c【准入拒绝】本服务器仅接受官方&l[服务端专用包]&r&c客户端。\n&7请从服务器公告下载专用包后重新连接。",
  "AuditLog": true,
  "ConsoleNotify": true,
  "Mode": 0,
  "DryRun": false
}
字段 默认 说明
Enabled true 总开关。false 时插件完全不干预连接(应急回退)
AllowedTokens ["TSC0DE1A"] 令牌白名单,大小写不敏感;可放多个(一服多包)
AllowedReleases [] release 白名单;留空 = 不限制
KickMessage 见上 踢出提示,支持 &c &a 等颜色标记与 \n 换行
AuditLog true 是否写审计行(IP / 原始串 / 令牌 / release)
ConsoleNotify true 踢出时是否在控制台告警
Mode 0 = Prefix 0 只校验令牌(推荐,release 升级不受影响);1 = ExactWithRelease 令牌 + release 双校验
DryRun false 干跑:仍完整解析并写审计,但不拦截、不接管握手

五、管理指令

权限:tshock.admin(控制台亦可执行)

/dedicatedguard                     查看状态(启用/干跑/模式/令牌白名单/release白名单/审计条数)
/dedicatedguard reload              热重载 config.json(无需重启服务器)
/dedicatedguard test <握手串>        模拟判定,不影响真实连接

    例: /dedicatedguard test TSC0DE1A326   → 放行
        /dedicatedguard test Terraria326   → 踢出(原版客户端)
        /dedicatedguard test EVIL12345     → 踢出(令牌不匹配)

改 config.json 后无需重启,/dedicatedguard reload 即可生效。 增删插件 DLL 仍需重启服务器。


六、编译与测试

# 编译插件
dotnet build plugin/DedicatedClientGuard/DedicatedClientGuard.csproj -c Release

# 运行单元测试(21 项,无需启动服务器)
dotnet run --project plugin/DedicatedClientGuard.Tests -c Release

测试项目只通过 <Compile Include> 引入 HandshakeParser.cs。该文件不依赖任何 TShock / Terraria 类型,因此可以在没有服务器环境的情况下独立测试。 覆盖场景包括原版串与专用包串的识别、大小写不敏感、畸形/超长/空输入、 tModLoader 风格后缀,以及读取偏移的回归用例(见 7.3)。

若测试项目目标框架(net9.0)与 SDK 自带运行时不一致,可用 DOTNET_ROLL_FORWARD=Major dotnet run ... 让其向上滚动到更高版本运行时。


七、实现中容易踩的坑

以下几处是开发过程中实际踩过的坑,均已修复并留了回归用例。再次修改本插件前请先读一遍。

7.1 TShock 插件加载的硬性条件

TShock 6 的插件加载器位于 bin/TerrariaServer.dll(内含 TerrariaApi.Server.LoadPlugins)。 逐个 DLL 加载时要求:

  1. 类型 public、非 abstract、是 TerrariaPlugin 子类;
  2. 必须带 [ApiVersion(2, 1)](ServerApi.ApiVersion = new Version(2,1,0,0))。 缺失就静默 continue,一行日志都不打印,表现为「DLL 放进去了但没加载、也没报错」;
  3. DangerousPluginDetector 会拦 TShock 官方标记的恶意程序集;
  4. 另有忽略名单 ServerPlugins/ignoredplugins.txt(按文件名无扩展名匹配)。

排查「插件没加载」先查 [ApiVersion],再看控制台 (TShock.Log.ConsoleError 不会写进日志文件)。

7.2 Json.NET 集合属性是「追加」语义

配置类里带初始值的集合属性:

public List<string> AllowedTokens { get; set; } = new List<string> { "TSC0DE1A" };

Json.NET 反序列化时默认把文件里的元素并入构造函数已初始化的列表(ObjectCreationHandling.Auto), 于是每加载一次配置就多一条重复令牌,config.json 里的令牌随重启次数线性增长。

必须显式声明:

[JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)]
public List<string> AllowedTokens { get; set; } = new List<string> { "TSC0DE1A" };

AllowedTokens 与 AllowedReleases 都已标注。

7.3 GetDataEventArgs 的 Index 是载荷起点,不是包类型位置

这是本插件最隐蔽的一个 bug,曾导致所有客户端(含专用包)全被拒。

// ❌ 错误:多跳 1 字节
int start = args.Index + 1;

// ✅ 正确
int pos = args.Index;          // 载荷起点

真实语义(反编译 + 实证双重核对):

字段 含义
args.Index 载荷起点,已跳过那 1 字节包类型;等于原版里的 num = start + 1
args.Length 整包长度(含那 1 字节包类型)

依据:

  • 反编译 TerrariaServer.dll → HookManager.InvokeNetGetData:Index = e.ReadOffset, 而 OTAPI 中 ReadOffset 的初值即 num = start + 1;随后原版正是 reader.BaseStream.Position = num。
  • 上游生态中读取同一缓冲的写法一致(例如 Chameleon 读 PasswordSend 用的是 new MemoryStream(readBuffer, args.Index, args.Length - 1))。

Index + 1 会吃掉字符串的 7-bit 长度前缀,ReadString() 便把首字符 ('T' = 0x54 = 84)当成 84 字节的长度,缓冲里没有那么多字节,于是 抛 Unable to read beyond the end of the stream,落入「解析失败」兜底,结果全部拒绝。

正确读法是自行解析 7-bit 变长长度前缀,不依赖 args.Length 的口径:

int pos = args.Index;
int len = 0, shift = 0;
while (true)
{
    byte b = buf[pos++];
    len |= (b & 0x7F) << shift;
    if ((b & 0x80) == 0) break;
    shift += 7;
}
return Encoding.UTF8.GetString(buf, pos, len);

回归用例:回归:偏移 = 载荷起点时可还原、回归:误用「载荷起点 + 1」必须失败。


八、安全边界与已知限制

这是一个准入门槛,不是反篡改系统,其能力边界需要说清楚:

  1. 令牌可被提取。令牌以明文存在于 APK 内,任何人都能反编译取得。 它防的是「普通玩家拿原版客户端直接进来」,不防有逆向能力者改包伪造令牌。
  2. 防不住二次分发。拿到专用包的人可以转发 APK。要提高门槛需叠加设备绑定或登录鉴权,超出本插件范围。
  3. 无法创造新的网络消息类型。TShock 的 NetHooks.OnReceiveData 会用 Enum.IsDefined(typeof(PacketTypes), id) 拦截未定义的 MessageID 并静默丢弃, 因此无法要求客户端回送自定义包做挑战—响应。握手串是唯一可用信道。
  4. 移动端 release 号需实测。Main.curRelease = 326 取自桌面版 1.4.5.8 的反编译源码; 移动端是独立构建,release 值可能不同。若专用包连不上,请以实包抓包为准, 并把实际值填入 AllowedReleases(或保持 Mode: Prefix 忽略 release)。
  5. 只拦 ConnectRequest,对已建立的连接无效;也不防 SYN 泛洪等传输层攻击。
  6. 令牌等长约束。客户端侧必须是 8 字节可打印 ASCII(与 "Terraria" 等长), 插件侧的令牌匹配则是大小写不敏感的子串前缀语义。

九、合规声明

本插件仅供自有服务器管理与安全研究使用。请遵守 Terraria / Re-Logic 的最终用户许可协议 及所在地法律法规,不得用于分发盗版、绕过商业授权或侵犯他人权益。

重打包的客户端仍受原 EULA 约束,请勿将其伪装为官方版本分发。


配套组件

组件 说明
客户端补丁工具 遍历 global-metadata.dat 字面量表,等长替换 "Terraria" 为专属令牌并重打包 APK
APK 重签名流程 zipalign + apksigner(v1 + v2 + v3),Android 7.0+ 必须带 v2/v3 签名才能安装
服务端采集器 从 TShock 发行包中提取编译期参考程序集(不属于本仓库,见 refs/README.md)

重签名后应用签名与 Google Play 版本不同,Google Play 内购会失效; 且包名未改时无法覆盖安装原版,需先卸载。发布更新版必须使用同一个密钥签名。


By ak · 基于 TShock 6.2.1 / Terraria 1.4.5.8 实测,握手解析逻辑 21/21 单元测试通过。

Releases

Packages

Contributors

Languages