一种使用 /transfer 的轻量化子服方案

一种使用 /transfer 的轻量化子服方案

李华是一个生电服的玩家,他总是在想,要是服务器里面有创造实验室就好了。

小叶是一个喜欢 PvP 的玩家,他总是在想,要是服务器里面有一个能干脆利落开启决斗的地方就好了。

作为这个服务器的 OP,这些场景对你来说不陌生。如果把这两个问题抛在 Minecraft 服务器社区里,相信绝大多数的回答都是「开一个新的子服务器并且建立跨服群组」,但是,这真的值得吗?

info

这篇文章在 blog stash 上有着更好的阅读体验

显然,这不值得。如果只是为了这两样功能启动一个跨服群组,你将会面临以下问题:

  • 额外的维护成本: 使用群组服则基本排除了面板服务器部署方案的可能性,并且一个没有适当配置过的群组服务器会引起诸多问题,例如:玩家使用 /server 跳过验证关卡,子服务器的端口意外的暴露在外网等等。
  • 资源需求上升: 启动额外的子服 + 代理程序则意味着你需要更多的资源。Java 主流 GC 的策略通常都会为了速度而将堆所占的空间悉数预分配,这导致即使程序使用的内存并没有那么多也会造成较高的 RSS.
  • 资源分配不灵活:上一个问题并不能简单的通过减少堆的大小解决。就以创造实验室为例,平时服务器内的人并不多,但一旦到了演示/大型机器实验时,较小的堆空间将会给 GC 带来相当的压力,并且无法利用到分配到其他(类似情况的,比如 PvP 竞技场)子服的空闲资源。
  • ……

光是想到从单端变群组,大多数服主就已经汗流浃背了。那么,本文所述的基于 transfer 机制的方案是如何规避这些问题的呢,他与其他类似方案又有何优劣之分呢?

基于世界的子服划分方式

这个情境中使用群组方案,归根结底是为了做服务器区域之间的划分。而 Minecraft 自带的多世界机制就已经很好的解决了活动空间的划分问题:没有传送门的情况下,玩家并不能随意大小跨。

早在 2013 年,PerWorldPlugins 就已经实现了将插件的生效范围降级到世界层次。通过切割插件在整个服务器中的作用域,这些世界得以模拟「服务器的子服」,使用此插件的用户可以通过限定不同世界里启用的插件来模拟多服务器的效果。而在约 2020 年,Valorin 的 DuelTime / Dantiao (付费) 将这个概念进一步推进,完全实现了本文引入部分时提到的「干脆利落开启决斗的地方」的设想。

然而,这样实现引入了许多需要额外维护的状态:生存服的玩家不能将物品带到创造服,他们也不希望在 PvP 服丢掉自己一身的好装备。插件需要小心翼翼的维护好这些状态,在跨世界转移的时候将他们的背包数据暂存起来,甚至有必要储存到硬盘上以防 崩服/断电/插件自身异常 导致数据丢失(数据无价!)。

并且,除了物品数据,还有许多问题需要考虑,比如玩家身上的 scoreboard / team 数据,其他插件储存的玩家相关的状态数据都可能会导致数据切割不干净。甚至如果这些插件涉及到储存功能,例如类末影箱类插件,邮箱等,被滥用的后果不堪设想。

而本文所述的方案使用 transfer 特性解决这些问题。

Transfer 是什么

/transfer 命令 是一项在 1.20.5 才被加入的新特性。如果你不知道他,只需要看这条命令的语法就可略知一二:

/transfer <hostname> <port> [players]

他可以让玩家的客户端主动连接加入另一个服务器。那么这有什么用呢?

首先我们要知道,Minecraft 中玩家是以 UUID 而不是名称确定的。因此,理论上可能发生这样的情况:

两个同名玩家在同一个服务器里, 图源 YggdrasilOfficialProxy, 作者 Karlatemp.

虽然他们的名字相同,但是他们的 UUID 不同,因此他们就是不同的玩家,而且账号数据也是毫不相干的(除了一些插件使用名称辨别,这是错误的做法)。

在一般情况下,玩家的 UUID,伴随着他的 GameProfile{name, id} 离开 Login State (也就是进入游戏)后就不可变了。如果想变更它的 UUID,我们只能在指派 Profile,也就是登录的时候做处理,否则会导致很多异常行为甚至数据损坏,服务端崩溃等。

但是这样做又引入了一个新的问题,也就是玩家体验不佳。他们需要在游戏内指定下一次登录时用的 GameProfile,然后退出重进,体验十分不好。并且对于离线玩家,退出重进时我们如何确定玩家的身份?IP + 玩家名并不可靠,如果你的服务器有为海外玩家设置的代理,情况将更加糟糕。

而 Transfer + Cookie 机制就可以很好的解决这个问题。Transfer 用于无缝转移玩家到新的 “子服” 中,Cookie 用于辨识玩家的身份,接下来本文将会开始分析这两者,并且给出应用实例以帮助您理解我所描述的过程。

Transfer 机制的原理

为什么 Transfer 能够让我们安全修改 GameProfile 呢?先从原理说起。如果你在一个服务器内使用 /transfer mc.hypixel.net 25565, 你将会得到一条错误信息。

transfer 失败

这是因为对端服务器并没有启用 transfer 导致的,但服务器是如何知道我们正在使用 transfer 的呢?

既然是在登录时出错,那么这段错误的区间应该能在 HandShake ~ Login 期间找到答案。翻阅文档,可以找到这一条目:

图源 wiki.vg

也就是说,客户端通过连接时候指定 nextState=3 标识该连接为 transfer 连接,并且,这个状态标识正好就在 Handshake 阶段。那么,如果我们 transfer 自己,玩家不就会把 Handshake → Play 的流程走个遍,而且我们还能知道他正在 transfer 回来了吗?

将 Handshake → Play 走一遍,也就意味着他将会重新启动登录流程。换句话说,也就是我们将有一个修改 Game Profile 的窗口期。现在,修改 GameProfile 的问题解决了一半,玩家只需要和跨世界一样等一下黑屏就行了——如果他不满意,还可以直接点击断线。现在来看问题的另一半——我们如何辨别这是刚刚请求跨服的玩家,而不是从其他服务器 transfer 过来的玩家呢?

与 Transfer 伴随而来的 Cookies 机制

注意!

Cookies 机制是一个高度实验性的机制,Minecraft Wiki 上也没有记载与其相关的命令,并且 Notchian(也就是原版)服务端通常会直接拒绝处理客户端发来的 Cookie Response.

伴随着 /transfer 来到 1.20.5 的还有新的 cookies 机制,对,就类似你想的那个浏览器上的 cookies.

Cookies 机制非常特别,它贯穿了 Minecraft 从 Login 到 Play 的所有 State。我们从 Login 开始就可以向客户端请求 Cookies, 而在 Play 阶段也仍然能操作 Cookies. 并且,客户端储存的 Cookies 数据对使用 /transfer 过程中的所有服务器可用,这使得本方案也易于拓展成多服务器无代理的形式,因为服务器间不需要额外同步状态(除了吊销令牌)

先来看看 Cookies 机制有关的三种包

Login State 下查询客户端 Cookie

Login State 下客户端对 Cookie Request 的回应

Configuration State 下服务器设置客户端 Cookie 的请求,注意有延迟

Cookie Request/Response 在 Play 到 Login 的所有 State 中都可用,封包格式一致。而 Store Cookie 比较特殊,他在 Configuration 往后可用。使用 Cookie 机制,我们就可以有效辨别跨服流程中的玩家,避免外源,而且不需要额外登录验证措施,因为 Cookies 数据只在 /transfer 中涉及的服务器里有效,当玩家主动离线后,Cookies 数据将会丢失。(这也是他的一个弱点)

由这两个机制,我们可以设计出这样一个简单的流程:

  • [PLAY] 玩家请求跨服(准备 Transfer)

    此时玩家会将需要加入的子服名称告诉服务器。

  • [PLAY] 储存 Cookies 到客户端

    我们将玩家请求的子服名称作为 Cookies 存入玩家的客户端中,准备开始”跨服”。

  • [PLAY] 进入重配置阶段

    我们发送 Start Configuration (0x69) 将玩家的客户端重置到 Configuration State.

    在这个阶段,我们可以安全的准备 Cookies 和房间设施。(注意时间长了是可以点 Disconnect 的,不是不能)

    进入重配置阶段的客户端 (Configuration State)

  • [CONFIG] 发送 Cookies 给客户端,并等待。

注意!

客户端支持的 Cookie 的 Value 负荷上限为 5KiB, 不要超过这个数值

  • [CONFIG] 开始 Transfer 到自己(发送 Transfer (0x0B)

    此时客户端从服务器断开连接,但他即将重新连接。

  • [LOGIN] Login State 下,我们可以确定客户端的 Cookies,为其依据 Room ID 计算一个新的 UUID 覆盖掉原本 GameProfile 中的结果

note

这个过程可以做到和正版验证不冲突,因此正版玩家仍然可以在登录之后才能进入服务器。你也可以直接根据 Cookies 直接放行

  • [PLAY] 玩家以”新玩家但同名”的身份成功加入游戏

    数据和之前同名字不同 UUID 的存档完全隔离,你可以安全的对他做任何事情,比如传送到一个新世界的地皮并且赋予创造模式。

以下是具体实现环节,命名均来自 1.21 Yarn Mapping。

实例:使用 /goto 在服务器内反复横跳

如果你喜欢直接看代码,可以直接看我的这个 spike test,但本文中的代码相对更加清晰一些(重写了)。

本篇文章讨论的特性正在 sfcraft 中被应用,欢迎留个 star。

接下来是我踩坑的总结

首先,为了辨别一下当前的 GameProfile, 写个提示工具:

1
2
3
4
5
6
private void onJoin(ServerPlayNetworkHandler handler, PacketSender sender, MinecraftServer server) {
var profile = handler.player.getGameProfile();
handler.player.sendMessage(Text.of("Your uuid: " + profile.getId()));
handler.player.sendMessage(Text.of("Is room player: "+ Helper.isRoomPlayer(profile)));
handler.player.sendMessage(Text.of("Room id:" + rooms.get(profile)));
}

这个工具的逻辑比较简单,因此不多赘述,他能帮助你了解后面发生了什么事。

过程的第一步是玩家请求 transfer, 这里我用一个命令 /goto <room> 来做,让我们看看这个命令的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
private int onGoto(CommandContext<ServerCommandSource> ctx) {
var room = ctx.getArgument("room", String.class);
var player = ctx.getSource().getPlayer();
if (player == null) {
return -1;
}
var network = player.networkHandler;
network.reconfigure();
network.send(new StoreCookieS2CPacket(IDENTIFIER_ROOM, room.getBytes(StandardCharsets.UTF_8)), PacketCallbacks.always(()->{
network.sendPacket(new ServerTransferS2CPacket("localhost", serverSupplier.get().getServerPort())););
}));
return 0;
}

乍看没什么,但是这里其实有个雷,就是我没有直接发 Start Configuration (0x69) 而是使用了 network.reconfigure(),他们之间有什么区别吗?有,大大的有,因为你要是直接发包

1
2
3
4
[23:18:10] [Server thread/INFO] (Minecraft) iceBear67[/127.0.0.1:38938] logged in with entity id 11 at (673.0034440744827, 73.7776368487581, 2774.6987246258886)
[23:18:10] [Server thread/INFO] (Minecraft) iceBear67 joined the game
[23:18:14] [Server thread/INFO] (Minecraft) iceBear67 lost connection: Internal Exception: io.netty.handler.codec.EncoderException: Pipeline has no outbound protocol configured, can\'t process packet net.minecraft.network.packet.s2c.play.ChunkDeltaUpdateS2CPacket@31cb9619
[23:18:14] [Server thread/INFO] (Minecraft) iceBear67 left the game

他就会爆

真的会爆

因为你光发这个包,两端的状态是对不上的,我们点开 configure 的实现就能看到:

1
2
3
4
5
6
public void reconfigure() {
this.requestedReconfiguration = true;
this.cleanUp();
this.sendPacket(EnterReconfigurationS2CPacket.INSTANCE);
this.connection.transitionOutbound(ConfigurationStates.S2C);
}

对比一下,如果你直接发,服务端这边并不会 transitionOutboundConfigurationState, 所以我们需要使用这个包装好的方法才能正常进入状态。

info

这里发 Transfer 最好放到 PacketCallbacks 中,避免可能的问题。

note

Server Transfer 包需要填写 IP 和 Port, 你可以手填,但还有一种更加稳定的方法。 在 Handshake 阶段,我们可以看到:

Handshake (0x00)

可以发现,客户端是会发送他们使用的 IP 和端口的。如果使用这里的凭据我们就可以适配上述的海外代理情形,而 Hypixel 实际上也使用这种方法来封禁 TCP 转发(虽然很容易被绕过)。说到这里,其实还可以用 Transfer 做代理 IP 检查,但是超出了本文的范畴,欢迎读者研究分享经验。

到这里为止,玩家进入了 Configuratation 状态,接收到了 Cookies 而且即将要开始他的 Transfer 了,我们要准备迎接他。

迎接 Transfer 玩家

在上一步结束之后,玩家会从服务器断线并且尝试重新加入(客户端提示: Transferring to new server

根据上面所述的流程,我们要在他进入 Login State 时指派他将使用的 GameProfile.

在 Yarn 命名表中,负责该流程的类为 ServerLoginNetworkHandler, 我们找到它的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class ServerLoginNetworkHandler implements ServerLoginPacketListener, TickablePacketListener{
// ... 无关代码被忽略
private volatile ServerLoginNetworkHandler.State state = ServerLoginNetworkHandler.State.HELLO;
private final boolean transferred; // <- 这就是上文说的标识

public ServerLoginNetworkHandler(MinecraftServer server, ClientConnection connection, boolean transferred) {
// ... 无关代码被忽略
this.transferred = transferred;
}
@Override
public void tick() {
if (this.state == ServerLoginNetworkHandler.State.VERIFYING) {
this.tickVerify((GameProfile)Objects.requireNonNull(this.profile));
}
// ... 无关代码被忽略
}

@Override
public void onHello(LoginHelloC2SPacket packet) { // 登录入口从这里开始
Validate.validState(this.state == ServerLoginNetworkHandler.State.HELLO, "Unexpected hello packet");
Validate.validState(StringHelper.isValidPlayerName(packet.name()), "Invalid characters in username");
this.profileName = packet.name();
GameProfile gameProfile = this.server.getHostProfile();
if (gameProfile != null && this.profileName.equalsIgnoreCase(gameProfile.getName())) {
this.startVerify(gameProfile);
} else {
if (this.server.isOnlineMode() && !this.connection.isLocal()) {
this.state = ServerLoginNetworkHandler.State.KEY; // 开始正版验证
this.connection.send(new LoginHelloS2CPacket("", this.server.getKeyPair().getPublic().getEncoded(), this.nonce, true));
} else {
this.startVerify(Uuids.getOfflinePlayerProfile(this.profileName)); // 离线玩家流程
}
}
}

void startVerify(GameProfile profile) {
this.profile = profile;
this.state = ServerLoginNetworkHandler.State.VERIFYING;
}

private void tickVerify(GameProfile profile) {
// 最后的检查...
this.sendSuccessPacket(profile); // 进入游戏
}
}

发现 ServerLoginNetworkHandler 已经提供了一个 transferred 标识,我们不需要做额外处理来获取他。我们来想一下一个问题,如果要做 GameProfile 的指派,我们就需要 Cookies, 那么这个 Cookies 哪里来呢?于是乎,我们需要发一个 Cookie Request.

Login State 下查询客户端 Cookie当客户端发来的 Cookie Response 到达后,他会调用该类里面的一个回调方法:

1
2
3
4
5
@Override
public void onCookieResponse(CookieResponseC2SPacket packet) {
// 从这里也可以看出是实验性功能
this.disconnect(ServerCommonNetworkHandler.UNEXPECTED_QUERY_RESPONSE_TEXT);
}

分析结束,接下来是实现环节。

实现功能

观察这个类的代码结构可以发现,一个玩家加入服务器首先会从 onHello 出发,接着判断正盗版之后选择启动正版验证流程(State.KEY)或者是直接开始准备进入游戏( startVerify

但是,startVerify 并不直接开始进入游戏流程,而是设置 state = VERIFING 之后直接返回,等待 tick 时根据 State 指派调用到 tickVerify. 而和正版验证有关的 State.KEY 也是回归到 startVerify 里处理。由此,我们可以选择在 startVerify 下钩子,这样就可以兼顾正盗版问题,并且不会引入更多复杂问题。

思考题

如果我们想让玩家绕过正版验证登录,应该怎么做呢?

startVerifyHEAD 处注入一段代码(接下来不会贴出全部代码)向玩家请求 cookie,并且阻止服务器进入 State.VERIFYING 状态,否则玩家会直接进入服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Inject(method = "startVerify", at = @At("HEAD"), cancellable = true)
private void sf$queryCookies(GameProfile profile, CallbackInfo ci) {
if (this.transferred) {
switch(this.sf$cookieState){
case NOT_SENT:
this.profile = profile;
this.connection.send(new CookieRequestS2CPacket(HintModule.IDENTIFIER_ROOM));
this.sf$cookieState = SENT;
break;
}
if(this.sf$cookieState != DONE){
ci.cancel()
return;
}
}
}

接着,我们要注入 onCookieResponse 从而得到对应的数据。

1
2
3
4
5
6
7
8
9
@Inject(method = "onCookieResponse", at = @At("HEAD"), cancellable = true)
private void sf$onRoomId(CookieResponseC2SPacket packet, CallbackInfo ci) {
if (packet.key().equals(SOME_SERVER_IDENTIFIER_KEY)) {
if(sf$cookieState != SENT) throw new IllegalStateException("Protocol error");
var payload = packet.payload();
sf$roomId = new String(payload);
}
ci.cancel();
}

这样我们就得到了子服务器的 ID,接下来我们要考虑恢复到 VERIFYING 状态的问题。

情况有两个:

  1. 没有正确 Cookie 的 Transfer: 玩家将会因为给不出 Cookie 超时从而掉线
  2. 给了 Cookie 并且正确: 进入 Profile 的指派流程

第一个情况不需要考虑,我们只需要考虑第二个。我选择的方法是在 tick() 尾巴多插入一段逻辑用于触发 startVerify:

1
2
3
4
5
6
7
@Inject(method = "tick", at = @At("TAIL"))
private void sf$awaitRoom(CallbackInfo ci) {
if (sf$cookieState == SENT && sf$roomId != null) {
sf$cookieState = COOKIE_RECV;
startVerify(this.profile);
}
}

到这里,又会回归到我们在 startVerify 下的 hook 里面。现在我们已经有了子服务器的 ID( sf$roomId ),可以继续正式开始指派了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Inject(method = "startVerify", at = @At("HEAD"), cancellable = true)
private void sf$queryCookies(GameProfile profile, CallbackInfo ci) {
if (this.transferred) {
switch(this.sf$cookieState){
case NOT_SENT:
this.profile = profile;
this.connection.send(new CookieRequestS2CPacket(HintModule.IDENTIFIER_ROOM));
this.sf$cookieState = SENT;
break;
+ case COOKIE_RECV:
+ // 根据 roomId hash 出一个新的 profile 以此做到数据隔离
+ // 当然在这里你还可以检查一下冲突情况
+ this.profile = Helper.generateProfileForRoom(sf$roomId, this.profile); // 此处是之前存进去的 profile
+ // 但是要注意:参数里的 profile 是原本的 profile,如果这里不 cancel 的话会把你设置的 profile 覆盖掉
+ // 你也可以把这段注入到 TAIL 来避免问题
+ this.sf$cookieState = DONE;
+ this.startVerify(this.profile);
+ ci.cancel()
+ return;
}
if(this.sf$cookieState != DONE){
ci.cancel()
return;
}
}
}

到此,指派 Profile 的过程就完毕了。如果你的服务器和我的一样开了白名单,那么还要额外给予赦免。

info

在 Cookies 被检查过的情况下,可以认为玩家的连接是可信的(但我这里没做签名校验)

1
2
3
4
5
@Redirect(method = "tickVerify", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/PlayerManager;checkCanJoin(Ljava/net/SocketAddress;Lcom/mojang/authlib/GameProfile;)Lnet/minecraft/text/Text;"))
private Text sf$bypassRoomPlayer(PlayerManager instance, SocketAddress address, GameProfile profile) {
if (sf$cookieVerified) return null;
return instance.checkCanJoin(address, profile);
}

以下是示例:

第一次进入服务器,并且即将进入 test_room ![中间过渡]/(attachment/e7ab3fc4-c988-4b63-8666-cc6cbecc28fa.png “ =862x516”)

以一个新的 UUID 重新加入了游戏,注意 room_id 被正确读取,我在文章之外写了一部分状态管理

这篇文章完整的技术 demo 可以在 这里 找到

End / 总结

总结一下。

优势

使用 Transfer 机制可以帮助你让玩家拥有多个不同的账号数据(也就是存档),并且你可以根据这个特性设计出很多好玩的东西,而不只是子服。

再者,使用此方案较 BungeeCord / Velocity 类更加轻量,在开头所述的轻量级子服情境下有着更高的资源利用率,且无论是腐竹还是开发者的运维/状态维护状态都更低。

该方案实现的多存档可以完美的抵御掉电/插件 bug 带来的切换账号时数据丢失,而且 cookies 的应用也使得服务器能够在 transfer 的窗口期间确定盗版玩家的身份,避免同 IP 情况卡着窗口期错误登录或者是其他特殊的情况,并有望减少多个服务器间状态同步的棘手问题。

缺点

难以对服务器进行扩容,且群组服情境下要使用这个方案较为复杂,需要代理支持。

机制术语表

Transfer —— 一个让客户端重新启动加入服务器流程的机制

Cookies —— 配合 Transfer 机制使用,可以(仅)在 Transfer 期间让玩家客户端储存自定义数据,大小不超过 5KiB

保留节目

这个功能其实主要也是服务器需求推动的,文章开头的两个需求都是真实的例子,而我也确实是面板服(悲)

自从去年高考之后已经很久没有更新文章了,这次刚好有材料写了,一次性更个大的,感谢大家支持。

感觉 Hexo 越来越难用了,没准我会迁移到 Outline 上面去。。

🥰 Thank you for reading this

作者

iceBear67

发布于

2024-07-17

更新于

2024-07-18

许可协议

评论