天空 Blond - 点对点
type: channel
link: https://www.skyblond.info/tag/%E7%82%B9%E5%AF%B9%E7%82%B9/
items: 【代码札记】从零开始的点对点聊天系统 P5
items: 【代码札记】从零开始的点对点聊天系统 P4
items: 【代码札记】从零开始的点对点聊天系统 P3
items: 【代码札记】从零开始的点对点聊天系统 P2
items: 【代码札记】从零开始的点对点聊天系统 P1
items: 【代码札记】从零开始的点对点聊天系统 P0

【代码札记】从零开始的点对点聊天系统 P5
type: item
date: 2022-12-07T01:24:00+08:00
description: 本文继续介绍如何将前文的i2p-p2p-chat接入Minecraft中。这是本系列的最后一篇文章。上文说上文说到了启动节点。但是之后还得发消息，除了公屏发消息，还得有一个私信消息的入口，除此之外还要让用户能够在游戏内复制到他的I2P地址，然后发送给其他玩家，让其他玩家在游戏内连接到这个地址，这就引出了一个指令系统。最后为了便别哪些玩家连上了我们，我们还希望有一个地方能展示这些信息。拦截消息为了避免意外发送消息从而被举报/陷害（还记得一开始我提到的gaslighting漏洞吗），最好的办法当然就是直接拦截所有消息，除非用户使用特别的命令要求向MC的公屏中发送消息。经过一番搜索，最终发现在ClientPlayerEntity类中，sendChatMessageInternal方法是整个发送消息过程中最关键的一环，它接收各种形式的调用（例如用户在聊天框中使用回车，或者其他mod以用户的名字发送消息），将实际要发送的消息封装成数据包发送给服务器。也就是说，只要我们能够拦截这个方法，我们就可以终止消息发送，并获得待发送的消息。得益于mixin的cancellable特性，我们可以指示mixin框架提前终止方法的调用，即插入我们自己的return指令：@Mixin(ClientPlayerEntity.class) public abstract class MixinClientPlayerEntity { @Inject(method = "sendChatMessageInternal", at = @At("HEAD"), cancellable = true) private void onSendChatMessageInternal(String message, Text preview, CallbackInfo ci) { if (ChatHelper.shouldSendPlain(message)) { // this message should be sent through minecraft's internal chat // stop further processing return; } if (CommandHelper.isCommand(message)) { CommandHelper.handleCommand(message); } else { // broadcast message var playerList = MinecraftHelper.getPlayerList(); ClientModEntry.getPeer().sendRequest( new TextMessageRequest("broadcast", message), s -> s.useContextSync(c -> c.getUsername() != null && playerList.contains(c.getUsername())) ); // show the message to ourselves ChatHelper.addBroadcastMessageToChat( Objects.requireNonNull(MinecraftHelper.getCurrentUsername()), Text.literal(message) ); } // cancel sending ci.cancel(); } }这里有三个设计，首先是决定要不要通过MC原本的机制发送消息，如果是，则直接跳过我们下面的代码，进入MC原版的流程。然后是判断用户的输入是待发送的消息，还是输入给mod的指令。指令系统的设计将在下一节描述。最后是发送公屏消息，在原版MC中，消息发送给服务器，服务器转发给包括发送者在内的所有玩家，这个时候玩家自己也能看到消息。但在我们的Mod中，玩家并不与自己建立连接，因此把消息发送给其他人后，还要自己把发送的消息添加到聊天框中。关于使用MC原本机制发送消息的判断，这里引入了如下机制： @JvmStatic private val plainMessagePendingQueue = ConcurrentHashMap<String, Int>() private fun addPlainMessage(message: String) { plainMessagePendingQueue.compute(message) { _, oldCount -> // add 1 if is old message, otherwise count as 1 for new messages oldCount?.let { it + 1 } ?: 1 } } @JvmStatic …

【代码札记】从零开始的点对点聊天系统 P4
type: item
date: 2022-12-06T18:27:00+08:00
description: 本文介绍如何将前文的i2p-p2p-chat接入Minecraft中。准备工作前文中我们完成了i2p-p2p-chat的编写，这个库封装了所有基于I2P的点对点通讯的操作。本文中，我们需要在Minecraft这部分做出修改。首先是要找到拦截和注入消息的位置，拦截到的消息将有我们的模组发送，而模组接收到的消息需要注入到Minecraft原本的聊天框中。然后就是想办法引入一套命令系统：公屏聊天当然不需要，但我们还得能够私聊，况且我们还得想办法让用户分享他们自己的地址。最后我们还得搞一套简单的配置系统，用来持久化地存储我们的私钥和曾经见过的节点。配置系统配置系统应该是整个模组最简单的部分了。我们的模组有两个需要存储的配置：一个是控制模组行为的配置文件；另一个则是存储见过的节点。前者无需过多解释，对于后者，建立这种缓存是为了当我们在服务器中遇到以前见过的用户名，则可以直接向过去见过的地址发起连接。因为我们的隧道私钥是随Minecraft实例持久化保存的，除非用户选择刷新私钥或者更换了实例，在大多数情况下，我们应该可以通过旧的地址直接连上用户，从而跳过了PEX和手工分享地址。这也使得整个mod的体验随着使用时间增加而变好。配置文件的格式当然是JSON，虽然后者用KV数据库性能会更好，但是我的宗旨是能不用数据库就不用数据库，最好一个map就能解决。两个配置的模型定义如下：@Serializable class MCAConfig( @Serializable(with = ByteArrayAsBase64Serializer::class) private var _i2pKey: ByteArray = I2PHelper.generateDestinationKey(), @Serializable(with = DurationSerializer ::class) var pexInterval: Duration = Duration.ofSeconds(45), @Serializable(with = DurationSerializer::class) var lastSeenUpdateInterval: Duration = Duration.ofMinutes(3), @Serializable(with = DurationSerializer::class) var sessionRemoverInterval: Duration = Duration.ofMinutes(2), @Serializable(with = DurationSerializer::class) var autoConnectInterval: Duration = Duration.ofSeconds(15), ) { val i2pDestinationKey get() = _i2pKey fun discardI2PKey() { _i2pKey = I2PHelper.generateDestinationKey() } } @Serializable class PeerRepoEntry( @Serializable(with = DestinationSerializer::class) var destination: Destination, @Serializable(with = ZonedDateTimeAsTimestampSerializer::class) private var _lastSeen: ZonedDateTime ) { val lastSeen get() = _lastSeen fun seen(dest: Destination) { destination = dest _lastSeen = ZonedDateTime.now() } } 其中MCAConfig就是控制行为的配置文件了。里面的_i2pKey就是我们I2P隧道的私钥，他的类型是ByteArray，但实际用的时候需要转换成Destination，于是就有了后面的i2pDestinationKey，当然，为了实现刷新功能，还有discardI2PKey方法。其他的各种Interval则决定了定时任务的执行间隔，比如多久进行一次PEX，多久记录一次最后见到的节点信息，多久进行一次会话检查（关闭不在同一个服务器中的会话），多久尝试一次从缓存中连接节点。下面的PeerRepoEntry就是一个节点缓存条目。它记录了节点的地址和最后一次见到的时间。这里本来想实现一个简单的最久淘汰策略，但是后来给忘了。不过忘了也不会有太严重的后果，一个条目不会超过1KB，因此见过一千个玩家才占用1MB空间，见过一百万个玩家才占用1GB空间。…

【代码札记】从零开始的点对点聊天系统 P3
type: item
date: 2022-12-05T15:14:00+08:00
description: 本文介绍如何使用前文构造的节点，以及Minecraft模组编写的一些内容。上一篇文章中介绍了这套系统中关于I2P部分的设计，这一篇文章中将介绍如何在Minecraft中使用。节点使用眼尖的同学可能已经注意到了，上一篇中我们并没有在节点中处理消息，而是将处理的部分委派给了GeneralIncomingMessageHandler和MessagePayloadHandler。但你会发现，诸如PEX这种比较内部的消息，好像也需要下游使用者自己处理，这是否有些不方便了？我也是这么想的，所以在正式使用之前，还要介绍一下预先实现的一些Handler。内置Handler这些Handler都是基于MessagePayloadHandler实现的，并且通过kotlin的inline和reified关键字，可以在一定程度上消除既要给出泛型，又要给出KClazz实例的情况： object : MessagePayloadHandler<C, SomeKindOfPayload>(SomeKindOfPayload::class) { // ...... }就这一个声明就要重复两次相同的内容，你说烦不烦？反正我很烦。但是有了reifield，事情就简单多了：inline fun <C : SessionContext, reified T : MessagePayload> createMessageHandlerWithReply( crossinline block: ( peer: Peer<C>, session: PeerSession<C>, messageId: UUID, payload: T, threadPool: ThreadPoolExecutor ) -> MessagePayload ): MessagePayloadHandler<C, T> = object : MessagePayloadHandler<C, T>(T::class) { override fun handleTyped( peer: Peer<C>, session: PeerSession<C>, messageId: UUID, payload: T, threadPool: ThreadPoolExecutor ): MessagePayload = block(peer, session, messageId, payload, threadPool) }这个函数创建了一个带有响应的处理器。调用时，Kotlin编译器可以通过上下文推断出C和T的具体类型，并且在编译时将内联替换回原位，这样便可以拿到泛型的KClazz实例了，就像这样：fun <ContextType : SessionContext> createPEXRequestHandler( logger: KLogger, filter: (PeerInfo) -> Boolean, onException: (Throwable, PeerInfo) -> Unit ): MessagePayloadHandler<ContextType, PEXRequest> = createMessageHandlerWithReply { peer, session, messageId, payload, threadPool -> threadPool.execute { peer.handlePEX(session, payload.peerInfos, logger, filter, onException) } PEXReply(messageId, peer.dumpKnownPeer()) }这里我们创建了一个处理PEXRequest的处理器，而我们只在函数返回值那里写了一次类型，后面对于createMessageHandlerWithReply调用则直接省略了类型声明。要不说Kotlin是生产力呢。类似地，我们还可以创建一个针对PEXReply处理的，但由于这个回复不会再有回复了，可以利用如下函数创建：inline fun <C : SessionContext, reified T : MessagePayload> createMessageHandlerNoReply( crossinline block: ( peer: Peer<C>, session: PeerSession<C>, payload: T, threadPool: ThreadPoolExecutor ) -> Unit ): MessagePayloadHandler<C, T> = object : MessagePayloadHandler<C, T>(T::class) { overr…

【代码札记】从零开始的点对点聊天系统 P2
type: item
date: 2022-12-03T19:52:00+08:00
description: 本文介绍这个点对点聊天系统的节点设计。预备上一篇文章中介绍了这套系统中的消息结构设计，这一篇文章中将介绍一个节点是怎么实现的。在上一篇文章中，我决定采用异步消息的设计，但为了避免收到一个消息就启动一个线程，这样对操作系统调度有压力，在编写同步控制的时候也会有额外的麻烦，所以我决定参考Redis的处理方式。在正文开始之前，这里想做一个小铺垫，主要是I2PHelper这个类，我在里面写了一些工具方法，最主要的有两个： @JvmStatic fun runThread(name: String? = null, task: () -> Unit) { val t = I2PAppThread { try { task() } catch (t: Throwable) { logger.error(t) { "Uncaught exception cause thread terminated" } } } t.isDaemon = true name?.let { t.name = it } t.start() } @JvmStatic fun createThreadPool(nThreads: Int): ThreadPoolExecutor = ThreadPoolExecutor( nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, LinkedBlockingQueue(), { I2PAppThread(it).apply { isDaemon = true } }, ThreadPoolExecutor.CallerRunsPolicy() )这两个工具方法应该是用的最多的。I2P自己的库中提供了一个叫做I2PAppThread的类，他的做用与Thread类似，不同之处在于前者提供了I2P提供的OOM监听器，推荐在使用I2P相关资源的时候使用这个线程。所以基于这种推荐，在使用I2P相关资源的时候将待执行的操作包装到I2PAppThread里，以及让线程池构造I2PAppThread实例。不过据我后来观察，这个应该是作为嵌入式路由时使用的，也就是应用程序自己跑一个I2P路由的时候用。如果使用外部路由的话（就像这个库，它要求使用者自己单独运行一个I2P路由，运行时将通过I2CP连接到这个外部路由），这个OOM应该没有什么用，所以之后使用协程代替这部分的代码，应该不会引发什么问题。会话在开始介绍节点实现之前，我想介绍一下这个叫做会话（Session）的设计。对于一个节点来说，其他节点的信息无外乎两个：与对方节点建立的I2P连接和与这个连接相关的状态。前者是节点通讯的基础，后者则是应用程序正常工作所依赖的基础，例如对方节点的验证信息，前文提到但没有实现的各类计时器，还有来自应用程序的各种状态信息（会话上下文）。会话上下文为了尽可能保持通用，与连接相关的状态设计为一个接口：interface SessionContext { val sessionSource: SessionSource var peerInfo: PeerInfo? val nickname: String? fun isAuthed(): Boolean = peerInfo != null fun onAuthAccepted() fun isAccepted(): Boolean }这里的SessionSource是一个枚举：enum class SessionSource { CLIENT, SERVER }当会话来源为SERVER的时候，就说明这个会话是我们的Server部分创建的，也就是说，这个会话是别人发起的，我们接收的；如果是CLIENT，就意味着这个会话是我们的Client部分创建的，即我们发起，别人接收。这个字段对于这个库和后面MC聊天都没什么用，但是有些应用程序可能会依赖我们自己的角色产生不同的行为，这一点可以通过检查会话来源实现。考虑到我们在PEX动作中交换节点信息，与其单独维护一个列表来存储PeerInfo，我们不妨直接把PeerInfo存到每一个会话的上下文中。关于NickName，这个是一个后期引入的字段，主要是调试起来太麻烦。调试时节点的地址通常都是随机生成的，于是打印的日志也全部都是随机地址，虽然日志一开始会打印出来Peer1的地址是AAA，peer2的地址是BBB，但是这看起来还是太令人头大了。所以便引入了NickName字段，在通过认证后，可以从PeerInfo里提取用户名等信息作为这个会话的昵称，这样打印日志的时候就可以采用“昵称（地址）”的方式，即“Peer1（AAA）”，这样我们即看到了节点的地址，又看到了这个节点是Peer1，不会再被随机地址搞得乱七八糟了。后面还针对验证…

【代码札记】从零开始的点对点聊天系统 P1
type: item
date: 2022-11-29T18:52:31+08:00
description: 本文介绍这个点对点聊天系统的整体设计。其实在决定这一篇文章内容的时候犹豫了好久。周末看了Kotlin中文开发者大会，发现了协程这个东西，有点类似于Java即将实现的纤程——一种轻量化的线程，不需要操作系统来进行调度，因此能够节省线程切换引发的开销。看到了这种性能提升，我也想把他引入到这个系统中。但是这样的话又要等好久才能把代码定形，到时候指不定又看到了什么新东西，不断优化代码，最后文章怎么也写不出来。所以最后思来想去，决定先按照现在的样子写文章，以后再专门说优化。前言点对点系统是一种非常棒的框架。像是C/S和B/S这些传统框架中，总会有明确的主从关系。用户作为客户端，是被动的一方，需要向服务器，也就是主动的一方发起请求，然后获得主动方返回的结果。如果出于各种原因，客户端无法连接服务器，或者服务器崩了，那么整套系统也就不能用了。为了防止系统出现问题，领域内的先贤们搞出了诸如高可用、主从备份等各种各样的手段来尽量避免故障。而点对点系统则用另一种方式解决服务故障：如果把服务器分散给每一个用户，那么只要有用户使用，就有用户提供服务，而这个系统就永远也不会宕机。我想大家既然能点进来这篇文章，那应该都是用过BitTorrent的人。如果你对这个名字不熟悉，那你肯定知道「种子」和「迅雷」。BitTorrent就是一个点对点内容共享系统，系统中的每一个节点（参与者）既作为客户端向别人请求数据，又作为服务端向别人提供自己有的数据，从而实现了一种人人为我，我为人人的境界。但是点对点系统一般来说都比较难设计，即便设计出来了，使用体验上也不是很好。这就是为什么，现在能使用的大多数APP全部都是C/S结构的——要提升用户体验，只要在服务端投入即可。点对点系统要提升体验，就涉及到节点间互联质量，系统的公平机制，攻击防范等。节点间互联质量主要与网络有关，其中影响最大的就是防火墙。关于公平机制和攻击防范，主要是在设计上考虑如何防止系统被滥用，虽然比较复杂，但好在相对容易实现。本系列意图实现一个点对点的聊天系统，我想把主要的精力放在协议设计上，数据传输则委托给I2P处理。I2P也是由Java编写的，并提供了一个不错的API来供其他Java程序使用。此外为了防止I2P通讯部分的代码与MC部分的代码耦合在一起，我把这两部分的代码完全分开了。使用I2P处理通讯的部分放在了hurui200320/i2p-p2p-chat这个仓库，而与MC模组有关的代码则放到了hurui200320/MinecraftChatAlternative这个仓库里。关于I2P本身的内容我就不多说了，网络上有不少关于I2P的介绍。不过这里需要提及的一点是，I2P遵守的是比较纯粹的开源风格，也就是所谓的“As Is - No Warranty”。现在的主要开发者从创始人手中接下项目后，不接受任何来自社区的货币捐助，二十多年来完全靠他们对于隐私与自由的热爱在维护这个庞大的软件。我尝试着阅读过他的源代码，看到了2003年的Java，以及当时使用的构建工具。可以说在我的认知里，我还从来没有见过这样的项目。说回到“As Is - No Warranty”，作为开发者，他们会尽力让软件变得“能用”，但是他们并没有义务为你修复bug。所以如果你因为这个系列也喜欢上了I2P的功能，但在使用的过程中遇到了一些问题，我希望你可以自己动手做一些简单的调试工作，不要在语气上“指责”开发者（这一点经常无意识的发生，使得你在提出issue的时候听起来像是在指责你这个开发者在发布软件之前没有充分测试代码，从而给我带来了麻烦）。提供帮助，不要指责。协议设计关于这一套系统的协议，经过一番修修改改，最终是这个样子的。同步还是异步整个系统的消息采用异步的方式处理，从而实现复用同一条TCP链接。在早期设计中使用同步方式处理消息，这就意味着一个组件发出消息后必须占用这一条TCP直到收到回复，此时不能接收其他消息，也不能发送其他消息。这种情况下节点间的一条TCP连接就变成了半双工的方式，并且客户端和服务端需要轮流征用一条TCP，控制逻辑比较复杂。为了实现简单的全双工，可以让节点之间建立两条TCP连接，每条按单工方式使用。这样写起来非常简单：服务端只需要监听入站消息并处理，客户端使用单独的TCP连接发送出站消息。这种设计的好处是不需要太复杂的同步控制，但坏处也很明显，一对节点需要两条TCP，如果要和N个节点通讯，那就得2N条链接。如果其中一条链接中断了，我们便失去了发送或接收的能力，针对这种情况的错误处理又引入了额外的复杂性。总的来说，虽然异步设计会带来一定的复杂度，但整体框架建立起来之后的好处就非常可观了：单条链接实现全双工，可以提供类似Redis的单线程消息处理（和性能），整体消息设计也更加灵活。消息结构在消息本身的设计上，我采用了相…

【代码札记】从零开始的点对点聊天系统 P0
type: item
date: 2022-11-24T15:28:43+08:00
description: 本篇为该系列的介绍。该系列将讲述我为Minecraft构造一个点对点聊天系统的过程。起因事情的起因是微软和Mojang决定在Minecraft 1.19中引入消息签名和举报封禁系统。关于这个系统的争论这里就不多说了，这里我推荐大家观看Aizistral的Youtube频道，他制作了一系列视频讲述了这个机制的原理和潜在的漏洞。总地来说，微软和Mojang的这一系列操作，从技术上讲并不安全（可以利用诸如gaslighting等漏洞捏造举报记录并陷害别人），从道理上来讲也不合适（由此引发的封禁会导致正版玩家在封禁期间无法在任何版本上加入多人游玩，因为Mojang的登录认证服务器会拒绝处理你的请求，而这会影响到所有版本）。而Mod社区作为反击，出现了诸如NoChatReports等模组来禁用消息签名，进而阻止其他玩家举报使用者。而我已经想给Minecraft写mod很久了，但是苦于没有想法，也一直没有入门的动力。最近在研究I2P，全称The Invisible Internet Project，翻译过来就是隐匿网。它的功能比Tor还要厉害。Tor提供了隐匿互联网踪迹的功能，同时还提供了所谓的暗网（基于电路交换）；而I2P则压根不考虑隐匿互联网行踪，它直接搞了个Internet over Internet，它所使用的协议比Tor更加具有抵抗力。听说这个项目目前是由俄罗斯人领导开发的，只能说，俄罗斯还是高手在民间。因为I2P也是Java写的，我也刚好写Java，所以就想着能不能给这个项目贡献一些自己的力量，但发现自己的水平还是不太行，只能围绕着I2P做一些外围的开发。于是我便有了一个想法：如果我们劫持Minecraft的聊天系统，岂不是从根本上解决问题？在原版Minecraft中，发送聊天消息主要是这个流程：聊天相关的UI -> 处理消息（签名、加入数据包队列） -> 发送给服务器服务器收到玩家的消息之后，服务器首先检查消息签名，然后将用户信息与签名的消息一起转发给每一个人：收到服务器转发的消息 -> 验证签名 -> 放入聊天栏如果我们的模组能够劫持这个过程，在处理消息的时候将消息交给模组处理，从而避免将消息签名、发送给服务器；同时模组将接收到的消息直接放入聊天栏，这样不就完全绕开了他们的消息签名那一套？之后无论微软和Mojang怎么改，只要他们还在游戏内提供聊天功能，这个办法就永远不会过时。EULA？狗屁！结果为什么没有经过呢？因为经过是本系列后续文章的事儿。既然如此，说干就干。经过一番折腾，最终得到了两个Repo：hurui200320/i2p-p2p-chat：这个Repo里面的东西是基于I2P构建的一个点对点通信框架hurui200320/MinecraftChatAlternative：这个Repo则是基于上面的框架，为Minecraft 1.19.2编写的mod从结果来说，这是我第一次为Minecraft写mod，也是第一次设计点对点系统。要我自己说，我觉得还不错；但是客观上讲，其实还是差远了。作为Mod来说，这个mod缺乏用户友好的教程、文档和游戏内配置等；作为一个点对点系统来说，好多功能和细节还有待打磨，同时在设计这个系统的时候，对于防范攻击并没有太多的考量与设计，真要是用起来的话，还得重新审视一下这方面的问题。不过从过程来说，我确实学到了不少东西。我的毕业设计是做了一个可以集群部署的系统，集群内同步的方式依赖于第三方服务（例如可集群部署的数据库和Etcd），而在设计模式上也是C/S模式，具有明显的主从关系。但是点对点系统不太一样，当然也可以按照主从模式来设计——最开始我就是这样写的：每个节点有一个服务端和客户端，节点之间必须有两条TCP连接才可以通信：服务端的入站用来接收消息，客户端的出站用来发送消息。我当然知道TCP可以全双工，但是那样的话就要考虑复用和同步控制的问题。总之，随着项目的进行，我逐渐累积了一些点对点系统的经验，最终当然还是设计成了单个TCP连接。也正是出于此，我想将我在其中学到的一些经验整理出来与大家分享，因此也便有了这个系列。考虑到本篇只是起到一个开篇、引路的做用，因此就不多啰嗦了。我们下一篇见，大家敬请期待。