陶瓦内部协议与组网
本文档基于版本
v0.4.2源码整理。
本文档描述陶瓦联机的内部协议与组网方式。
陶瓦的联机分为两层:
- EasyTier 组网层:基于 EasyTier 构建 P2P 虚拟网络,房主与房客通过网络名/密钥加入同一网络,利用端口转发暴露服务。
- 脚手架协议层:房主在虚拟网络内运行一个脚手架 TCP 服务,房客通过该服务完成握手、获取 MC 端口、心跳、同步玩家列表。
一、邀请码与网络标识
房主创建房间后会得到形如 U/XXXX-XXXX-XXXX-XXXX 的邀请码(源码中对应 Room 结构体的 code 字段,即 room.code)。邀请码中编码了 EasyTier 网络所需的 network_name 与 network_secret,房客仅需邀请码即可加入同一网络。
字符集
邀请码使用 34 进制字符集(不含易混淆的 I 和 O):
解析时 I 会被纠正为 1,O 会被纠正为 0,且大小写不敏感。
邀请码格式
邀请码以固定前缀 U/ 开头,后面紧跟 16 个 34 进制字符,并按每 4 个一组用短横线 - 分隔,整体形如 U/XXXX-XXXX-XXXX-XXXX。这 16 个字符可以解码为一个 u128 整数 value,为了防止误输入,value 必须能被 7 整除,否则视为无效。
编码:从 value 生成邀请码与网络标识
生成时,从满足被 7 整除条件的 value 出发,连续对 34 取余 16 次,依次得到从低位到高位的 16 个字符 v0 到 v15(每一步先取 value % 34 得到当前字符,再将 value 除以 34 继续下一位)。这 16 个字符按位置拼接:邀请码是 U/ 加上完整的 16 个字符并每 4 个插入一个短横线;network_name 是 scaffolding-mc- 加上低 8 位 v0 到 v7(同样每 4 个字符插入短横线);network_secret 则取高 8 位 v8 到 v15,每 4 个字符插入短横线。
解码:从邀请码恢复 value
解析时,先将输入转为大写,并把其中的 I 视作 1、O 视作 0。随后在整段文本中滑动查找以 U/ 开头、长度为 21 个字符的片段(即 U/ 加上 XXXX-XXXX-XXXX-XXXX)。从该片段的最后一个字符 v15 开始向前逐位计算 value:每遇到一个非短横线字符,就执行 value = value * 34 + 该字符对应的数值,而位置 4、9、14 处必须是短横线。最终校验 value 是否能被 7 整除,不满足则该邀请码无效。这种滑动查找的方式意味着邀请码可以嵌在任意更长的文本中,而不要求它独占整段输入。
二、EasyTier 组网
陶瓦使用的 EasyTier 版本为 v2.5.0-terracotta.2。以下命令行参数适用于 easytier-core 可执行文件;也可使用等价的 EasyTier SDK / API 配置。
通用启动参数
房主与房客均需以下参数:
easytier-core \
--network-name "<network_name>" \
--network-secret "<network_secret>" \
-p "<节点>" \
--no-tun \
--compression=zstd \
--multi-thread \
--latency-first \
--enable-kcp-proxy \
-l "udp://0.0.0.0:0" \
-l "tcp://0.0.0.0:0" \
--p2p-only \
-r <rpc_port>
| 参数 | 说明 |
|---|---|
--network-name |
由邀请码派生的网络名 |
--network-secret |
由邀请码派生的网络密钥 |
-p |
EasyTier 公共节点,可重复指定 |
--no-tun |
不创建虚拟网卡 |
--compression=zstd |
使用 zstd 压缩 |
--multi-thread |
多线程模式 |
--latency-first |
优先低延迟路由 |
--enable-kcp-proxy |
启用 KCP 代理,提升弱网连通性 |
-l udp://0.0.0.0:0 / -l tcp://0.0.0.0:0 |
监听随机端口(UDP + TCP) |
--p2p-only |
仅使用 P2P 连接,流量不经过公共节点 |
-r <rpc_port> |
EasyTier RPC 控制端口,本地随机可用端口 |
内置公共节点
陶瓦默认追加以下公共节点:
集成方可自行追加更多节点(对应陶瓦 HTTP API 的 public_nodes 参数),追加的节点会与内置节点合并。
房主额外参数
房主还需指定:
--hostname "scaffolding-mc-server-<scaffolding_port>" \
--ipv4 "10.144.144.1" \
--tcp-whitelist=<scaffolding_port> \
--tcp-whitelist=<mc_port> \
--udp-whitelist=<mc_port>
--hostname:房主在虚拟网络中的主机名,格式固定为scaffolding-mc-server-加上脚手架服务端口。房客通过此前缀识别房主并解析脚手架端口。--ipv4 10.144.144.1:房主在虚拟网络中的固定 IP。--tcp-whitelist/--udp-whitelist:白名单端口,仅允许虚拟网络内访问脚手架端口与 Minecraft 端口。
房客额外参数
房客使用 DHCP 自动获取虚拟网络 IP,并将 TCP / UDP 白名单端口设为 0:
--tcp-whitelist=0/--udp-whitelist=0: 拒绝所有入站连接——房客只需通过端口转发主动连接房主,无需向虚拟网络暴露任何端口。确保房客本地网络不被外部访问。
节点发现
启动后,通过 easytier-cli 查询网络中的节点:
返回 JSON 数组,每个节点包含:
| 字段 | 说明 |
|---|---|
hostname |
节点主机名 |
ipv4 |
节点在虚拟网络中的 IPv4 地址 |
cost |
路由开销,"Local" 表示本机 |
nat_type |
NAT 类型 |
房客需要:
- 识别本机:
cost == "Local"的节点即本机,用于获取本地 NAT 类型。 - 识别房主:
hostname以scaffolding-mc-server-开头的节点即房主: - 房主虚拟 IP = 该节点的
ipv4 - 脚手架端口 =
hostname去掉scaffolding-mc-server-前缀后的数字部分
端口转发
EasyTier 支持两种端口转发方式,二者等价。
方式一:启动参数直接指定
在启动 easytier-core 时通过 --port-forward 参数指定转发规则,格式为:
easytier-core \
--port-forward="tcp://<local_addr>/<remote_addr>" \
--port-forward="udp://<local_addr>/<remote_addr>"
<proto>:tcp或udp。<local_addr>:本机监听地址与端口,如0.0.0.0:13448。<remote_addr>:远端(房主)目标地址与端口,如10.144.144.1:13448。
示例(同时添加脚手架与 Minecraft 的转发):
easytier-core \
--port-forward="tcp://0.0.0.0:<local_scaffolding_port>/<host_ip>:<scaffolding_port>" \
--port-forward="tcp://0.0.0.0:<local_mc_port>/<host_ip>:<mc_port>" \
--port-forward="udp://0.0.0.0:<local_mc_port>/<host_ip>:<mc_port>"
方式二:运行时通过 CLI 动态添加
在 easytier-core 启动后,通过 easytier-cli 向 RPC 端口发送指令动态添加规则:
# 脚手架端口转发(TCP)
easytier-cli -p "127.0.0.1:<rpc_port>" port-forward add tcp \
"0.0.0.0:<local_scaffolding_port>" "<host_ip>:<scaffolding_port>"
# Minecraft 端口转发(TCP + UDP,建议同时绑定 IPv4 与 IPv6)
easytier-cli -p "127.0.0.1:<rpc_port>" port-forward add tcp \
"0.0.0.0:<local_mc_port>" "<host_ip>:<mc_port>"
easytier-cli -p "127.0.0.1:<rpc_port>" port-forward add udp \
"0.0.0.0:<local_mc_port>" "<host_ip>:<mc_port>"
陶瓦房客侧实际采用运行时 CLI 动态添加的方式:先启动 EasyTier 加入网络并发现房主,获取到房主虚拟 IP 与端口后再执行端口转发。启动参数方式适用于已知远端地址、无需动态发现的场景。
- 陶瓦会优先尝试让
<local_mc_port>与房主的<mc_port>相同,以保证 SimpleVoiceChat 等依赖 UDP 的 Mod 正常工作;失败则退化为随机端口。 - 脚手架端口转发只需 TCP。
NAT 连接难度(可选,用于显示)
根据双方 NAT 类型估算连接难度,用于向用户提示:
- 若任一方为
OpenInternet,则难度为 Easiest; - 否则,若任一方为
NoPat或FullCone,则难度为 Simple; - 否则,若任一方为
Restricted或PortRestricted,则难度为 Medium; - 其余情况为 Tough。
nat_type 字段的取值:Unknown、OpenInternet、NoPat、FullCone、Restricted、PortRestricted、Symmetric、SymUdpFirewall、SymmetricEasyInc、SymmetricEasyDec。
三、脚手架协议
房主在虚拟网络内运行脚手架 TCP 服务(默认端口 13448,失败回退随机端口)。房客通过上文的端口转发连接到本地端口,使用本协议完成接入。
传输层
- 协议:TCP(IPv4 / IPv6)
- 超时:读写超时均为 64 秒
- 连接模型:每个房客与房主维护一条长连接,串行发送请求 / 接收响应
帧格式
请求帧(客户端 → 服务端)
请求帧依次由四部分组成:
kind_size(1 字节):kind字符串的字节长度(最大 255);kind(kind_size字节):UTF-8 字符串,格式为namespace:path,如c:ping;body_size(4 字节,大端无符号整数):body的字节长度;body(body_size字节):请求体,结构由具体协议定义。
响应帧(服务端 → 客户端)
响应帧依次由三部分组成:
status(1 字节):状态码,0表示成功,非0表示失败;body_size(4 字节,大端无符号整数):body的字节长度;body(body_size字节):响应体;失败时通常为错误描述文本。
未实现的协议返回 status = 255,body 为 Requested protocol hasn't been implemented.;处理异常同样返回 status = 255。
协议列表
当前所有协议位于 c 命名空间下:
| 路径 | 功能 |
|---|---|
c:ping |
握手 / 指纹验证 |
c:protocols |
列出服务端支持的全部协议 |
c:server_port |
获取房主 Minecraft 服务器端口 |
c:player_ping |
房客心跳与玩家注册 |
c:player_profiles_list |
获取房间内全部玩家列表 |
c:ping — 握手 / 指纹验证
验证连接的对端确实是陶瓦脚手架服务。
- 请求 body:固定 16 字节指纹:
- 响应 body:原样回显请求 body。
- 校验:比对响应是否等于指纹;不一致则放弃连接。
c:protocols — 列出支持的协议
- 请求 body:空。
- 响应 body:以
\0(NUL 字节)分隔的namespace:path列表。
当前返回:
c:server_port — 获取房主 Minecraft 服务器端口
- 请求 body:空。
- 响应 body(成功):2 字节大端无符号整数,即房主 Minecraft 服务器监听的端口。
- 失败:
status = 32,房主不在HostOk状态。
c:player_ping — 房客心跳与玩家注册
房客定期(每 5 秒)调用,上报自身信息并刷新心跳。
- 请求 body:JSON:
{
"name": "玩家名称",
"machine_id": "32 字符 hex 机器标识",
"vendor": "Terracotta 0.4.2, EasyTier v2.5.0-terracotta.2"
}
- 响应 body:空。
服务端行为:
machine_id已存在且非房主:更新心跳时间;若name变化则同步更新。machine_id是房主:返回错误。machine_id不存在:新增kind = GUEST的玩家。
超过 10 秒未心跳的房客会被自动移出列表。
c:player_profiles_list — 获取房间内全部玩家
- 请求 body:空。
- 响应 body:JSON 数组:
[
{
"name": "玩家名称",
"machine_id": "32 字符 hex 机器标识",
"vendor": "Terracotta 0.4.2, EasyTier v2.5.0-terracotta.2",
"kind": "HOST"
}
]
kind |
含义 |
|---|---|
HOST |
房主 |
GUEST |
房客 |
房主自身以 HOST 出现;房主本机的 LOCAL 玩家不会出现在此列表。
四、关键数据
MACHINE_ID
- 16 字节随机数的 hex 编码(32 个十六进制字符)。
- 首次启动时生成并持久化到数据文件,之后保持不变。注意:若数据文件无法打开(如权限问题),则每次启动时临时生成且不持久化——此边缘路径下当前实现生成 17 字节随机数(34 个十六进制字符),与正常路径长度不一致,集成方不应假定其长度恒为 32。
- 用于唯一标识设备,避免玩家重复计数。
VENDOR
- 格式:
Terracotta {terracotta_version}, EasyTier {easytier_version},如Terracotta 0.4.2, EasyTier v2.5.0-terracotta.2。 - 用于标识客户端来源与版本。
五、完整接入流程(房客视角)
- 解析邀请码:从邀请码派生
network_name与network_secret。 - 启动 EasyTier:使用通用参数 +
-d(DHCP)加入网络。 - 发现房主:通过
easytier-cli peer找到hostname以scaffolding-mc-server-开头的节点,获取房主虚拟 IP 与脚手架端口。 - 转发脚手架端口:将房主脚手架端口转发到本地,连接
127.0.0.1:<local_scaffolding_port>。 - 握手验证:发送
c:ping(携带指纹),校验响应。 - 获取 MC 端口:发送
c:server_port,读取 2 字节大端端口号。 - 转发 MC 端口:将房主 MC 端口转发到本地(TCP + UDP),并在本地启动一个假的 Minecraft 服务器作为「陶瓦联机大厅」入口(显示 MOTD
§6§l双击进入陶瓦联机大厅(请保持陶瓦运行))。 - 心跳与列表同步:每 5 秒发送
c:player_ping与c:player_profiles_list。
附录:代码示例
以下示例以 Python 编写,仅作参考实现,逻辑与陶瓦源码一致,可据此移植到其他语言。
邀请码解析
从任意文本中提取邀请码,并派生 EasyTier 网络名与密钥:
CHARS = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ"
SEGMENT_LEN = len("U/XXXX-XXXX-XXXX-XXXX") # 21
def parse_code(text: str):
"""从文本中解析陶瓦邀请码。
成功时返回 (network_name, network_secret),否则返回 None。
"""
text = text.upper()
for start in range(len(text) - SEGMENT_LEN + 1):
segment = text[start:start + SEGMENT_LEN]
if segment[0] != 'U' or segment[1] != '/':
continue
# 从最后一个字符 v15 开始向前逐位计算 value
value = 0
valid = True
body = segment[2:] # "XXXX-XXXX-XXXX-XXXX"
for i in range(len(body) - 1, -1, -1):
if i in (4, 9, 14):
if body[i] != '-':
valid = False
break
continue
c = body[i]
if c == 'I':
c = '1'
elif c == 'O':
c = '0'
if c not in CHARS:
valid = False
break
value = value * 34 + CHARS.index(c)
if not valid or value % 7 != 0:
continue
# 从 value 恢复 v0..v15,拼接网络名与密钥
digits = []
v = value
for _ in range(16):
digits.append(CHARS[v % 34])
v //= 34
low = "".join(digits[0:8]) # v0..v7
high = "".join(digits[8:16]) # v8..v15
network_name = f"scaffolding-mc-{low[:4]}-{low[4:]}"
network_secret = f"{high[:4]}-{high[4:]}"
return network_name, network_secret
return None
脚手架客户端
演示帧编码、握手验证与获取房主 MC 端口:
import json
import socket
import struct
FINGERPRINT = bytes([
0x41, 0x57, 0x48, 0x44, 0x86, 0x37, 0x40, 0x59,
0x57, 0x44, 0x92, 0x43, 0x96, 0x99, 0x85, 0x01,
])
class ScaffoldingClient:
"""陶瓦脚手架协议客户端。读写超时均为 64 秒。"""
def __init__(self, host: str = "127.0.0.1", port: int = 13448):
self.sock = socket.create_connection((host, port), timeout=64)
self.sock.settimeout(64)
def request(self, kind: str, body: bytes = b"") -> bytes:
"""发送一条请求并返回响应 body。失败时抛出 RuntimeError。"""
kind_bytes = kind.encode("utf-8")
assert len(kind_bytes) <= 255
# 请求帧:kind_size(1) + kind + body_size(4, BE) + body
frame = (
bytes([len(kind_bytes)])
+ kind_bytes
+ struct.pack(">I", len(body))
+ body
)
self.sock.sendall(frame)
# 响应帧:status(1) + body_size(4, BE) + body
header = self._read_exact(5)
status = header[0]
body_size = struct.unpack(">I", header[1:5])[0]
data = self._read_exact(body_size)
if status != 0:
raise RuntimeError(
f"请求 {kind} 失败,status={status}: {data.decode(errors='replace')}"
)
return data
def _read_exact(self, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = self.sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("连接已断开")
buf += chunk
return buf
# 接入流程示例(local_scaffolding_port 为脚手架端口转发到本地的端口)
client = ScaffoldingClient(port=local_scaffolding_port)
# 1. 握手 / 指纹验证
assert client.request("c:ping", FINGERPRINT) == FINGERPRINT
# 2. 获取房主 Minecraft 端口
mc_port = struct.unpack(">H", client.request("c:server_port"))[0]
# 3. 心跳上报(每 5 秒一次)
client.request("c:player_ping", json.dumps({
"name": "玩家名称",
"machine_id": "0123456789abcdef0123456789abcdef",
"vendor": "MyLauncher 1.0",
}).encode("utf-8"))
# 4. 获取玩家列表
players = json.loads(client.request("c:player_profiles_list"))