EasyTier Cloudflare Relay — 技术文档
已完成功能清单 + 协议实现细节,逐项对标官方 easytier-core (Rust) 实现。 实现版本:easytier-cf-relay/1.3.0 | 协议对齐:easytier-core 2.6.4(源码 tag v2.6.4) 部署与使用见《部署手册》。
1. 项目背景与设计目标
EasyTier 官方服务端是 Rust 单二进制(easytier-core),需要一台有公网 IP 的 VPS 才能自建节点。
本项目目标:在 Cloudflare Workers 免费额度内运行一个协议兼容的 WebSocket 节点,
让客户端(-p wss://...)无需 VPS 即可完成组网 rendezvous 与(可选的)数据中继。
设计原则(按优先级):
- 协议正确性:字节级对齐官方实现,用真实官方客户端验证;
- 成本趋零:全 Hibernation API,无 setInterval 常驻;
- 纯 P2P 优先:节点只做控制面 + 兜底中继,鼓励客户端直连;
- 健壮性:幽灵节点、半开连接、重复连接、脏持久化数据都有明确处理路径。
2. 已完成功能清单
2.1 协议层(对标官方 easytier-core)
| 功能 | 官方对应实现 | 状态 |
|---|---|---|
| ZCPacket / PeerManagerHeader 解析与构造(16B 小端) | packet/mod.rs |
✅ |
| WebSocket 隧道(任意路径升级) | tunnel/websocket.rs |
✅ |
握手(magic 0xd1e1a5e1、version、features 镜像、网络摘要校验) |
peers/peer_manager.rs |
✅ |
| network_secret 摘要校验(SipHash-1-3,与官方同算法同密钥) | peers/peer_manager.rs |
✅ |
| Ping/Pong 心跳(含 liveness-echo flags 镜像) | peers/peer_manager.rs |
✅ |
| RPC 分发(RpcPacket/RpcRequest/RpcResponse,int64 transaction_id 全精度) | rpc/ + rpc_types |
✅ |
| OspfRouteRpc.SyncRouteInfo(请求处理 + 响应 + 主动推送) | peers/peer_ospf_route.rs |
✅ |
| 路由增量同步(version / peer_route_id / session 语义) | peer_ospf_route.rs |
✅ |
| RoutePeerInfo 未知字段逐字节保留(wire 层重组) | route_peer_wire.rs raw 机制 |
✅ |
| PeerCenterRpc.ReportPeers / GetGlobalPeerMap(官方 replace 语义 + digest 缓存) | peers/peer_center/ |
✅ |
| 包转发(forward_counter 递增、>7 丢弃、转发清 LATENCY_FIRST) | peers/peer_manager.rs |
✅ |
| 同 peerId 重连顶替(旧连接关闭 + 路由更新广播) | peers/peer_manager.rs |
✅ |
| 加密包透传(ENCRYPTED flag 的转发不受影响) | peers/peer_manager.rs |
✅ |
| 分组隔离(网络名+摘要 → 独立分组,互不可见) | 官方按网络实例隔离 | ✅ |
2.2 平台层(Cloudflare)
| 功能 | 状态 |
|---|---|
| Worker 入口(/health、自定义安全路径 /metrics 与管理端、WebSocket 升级、房间路由) | ✅ |
| Durable Object 房间(acceptWebSocket Hibernation API) | ✅ |
| Alarm 驱动清扫(无 setInterval,DO 可休眠) | ✅ |
| attachment 元数据(休眠唤醒后恢复 peerId/lastSeen 等) | ✅ |
| 路由状态持久化(脏标记 + 节流落盘 + 损坏数据静默丢弃) | ✅ |
| 多房间分片(Header/Query) | ✅ |
2.3 运维与安全
| 功能 | 状态 |
|---|---|
| 幽灵节点六重防线(见 §6) | ✅ |
| STRICT_DIGEST 防网络名抢占 | ✅ |
| NETWORK_SECRETS 服务端密钥校验 + 摘要注册表自愈 | ✅ |
| MAX_MESSAGE_BYTES / MAX_PEERS_PER_ROOM 限制 | ✅ |
| /health 零指纹 | ✅ |
| /metrics 自定义安全路径 + token 双门禁,fail-closed | ✅ |
| 转发前校验 from_peer_id === 连接注册身份,伪造丢弃计数(v1.4.2 起覆盖全部 RPC 路径) | ✅ |
| 路由 direct/transit 来源标记,transit 不覆盖 direct | ✅ |
| PeerCenter 上报以连接身份为准 | ✅ |
| 路由老化 + 空分组自动删除(无在线节点后自动清理,见 §6 e/f) | ✅ |
| Web 管理端(侧边栏列表式导航 + 服务端分页 + 各块统计 + 批量操作,CLI 命令对照) | ✅ |
| 管理端操作:踢出节点 / 删除分组 / 删除路由条目 / 删除互联条目 / 删除摘要注册 / 断开连接(均支持单个与批量) | ✅ |
| KV 审计记录(六类事件各占一条 KV 键,管理页可单独开关并设上限,支持"全部"跨类型合并查询,见 §9) | ✅ |
| 管理端硬审计(登录 IP/时间、操作;wrangler.toml 硬设置,管理页不可关闭,见 §9) | ✅ |
| 管理端黑名单(peer/group/digest/socket 四类各占一条 KV 键,操作自动拉黑 + 边缘层/DO 双层拦截接入,见 §9) | ✅ |
| 结构化日志(debug~error 四级) | ✅ |
| 结构化日志(debug~error 四级) | ✅ |
| Prometheus 文本输出(/metrics?format=prometheus,16 个 easytier_ 指标,v1.6.0) | ✅ |
| 账号级真实额度(GraphQL Analytics API,token 仅服务端使用,缓存 5 分钟,v1.6.0) | ✅ |
| Analytics Engine 趋势打点 + 管理页 SVG 趋势图(未绑定 AE 静默降级,v1.6.0) | ✅ |
| 审计数据最小化(AUDIT_IP_MASK 打码 / AUDIT_RETENTION_DAYS 留存清理,v1.6.0) | ✅ |
| 资源滥用防线(帧条目/条目字节/分组总量/直连表上限 + 出站硬闸 + 双冷却 + 抢占可观测,v1.6.0,见 §6.1) | ✅ |
2.4 验证
- 静态检查:
node --check全部源文件语法通过;wrangler deploy --dry-run构建通过; - 真实客户端实测:easytier-core 2.6.4 官方二进制,双节点互见 + 数据中继 + 长稳运行(详见 §11)。
3. 与官方实现的总体对比
| 维度 | 官方 easytier-core | 本实现 (easytier-cf-relay) |
|---|---|---|
| 语言/运行时 | Rust / 自托管 | JavaScript / Cloudflare Workers |
| 传输面 | TCP/UDP/WS/WSS/QUIC/WG/FakeTCP 监听 | 仅 WebSocket(Cloudflare 边缘终结 TLS) |
| P2P 打洞 | 完整(UDP/IPv6/多路径) | 不执行打洞(无 UDP 能力),仅提供 rendezvous |
| 数据中继 | 支持 | 支持(可配置关闭) |
| 路由协议 | OSPF 完整实现(本端作为节点参与) | OSPF 邻居侧实现:同步/推送/收敛由客户端 OSPF 完成 |
| KCP/QUIC 隧道 | 支持 | 转发其控制/数据包类型(不做协议转换) |
| secure mode (Noise) | v2.6.0+ 支持 | ❌ 不支持(客户端用默认模式即可) |
| 部署成本 | VPS | 免费额度内的 Workers + DO |
关键差异说明:本实现不参与 OSPF 最短路径计算本身,而是把客户端上报的
RoutePeerInfo原样存储并转发给同网络其他客户端(星型控制面), 路径计算完全由客户端的 OSPF 完成——这正是官方公共服务器在拓扑中的角色。
4. 协议实现详解
4.1 ZCPacket / PeerManagerHeader(对标 packet/mod.rs)
每个 WebSocket 二进制帧 = 16 字节头 + payload,全部小端:
偏移 长度 字段
0 4 from_peer_id u32 LE
4 4 to_peer_id u32 LE
8 1 packet_type u8 (见下表)
9 1 flags u8 (bitfield)
10 1 forward_counter u8
11 1 reserved u8
12 4 len u32 LE (= payload 长度)
PacketType(与官方枚举值逐一核对):
Data=1 HandShake=2 Ping=4 Pong=5 RpcReq=8 RpcResp=9
ForeignNetworkPacket=10 KcpSrc=11 KcpDst=12
NoiseHandshakeMsg1/2/3=13/14/15 QuicSrc=16 QuicDst=17
RelayHandshake=20 RelayHandshakeAck=21
flags 位(bitflags 对齐):ENCRYPTED=0x01 LATENCY_FIRST=0x02 EXIT_NODE=0x04 NO_PROXY=0x08 COMPRESSED=0x10 LIVENESS_PROBE=0x20 NOT_SEND_TO_TUN=0x40 LIVENESS_ECHO=0x80。
转发语义(peer_manager.rs):
forward_counter每跳 +1,> 7 丢弃;forward_counter > 2时清除LATENCY_FIRST位。
历史踩坑:早期版本曾按 16bit 解析 packet_type(照抄了某个错误第三方实现), | QuicSrc/RelayHandshake 枚举值也被抄错。本实现已逐一对照官方源码修正并锁定测试。
4.2 Protobuf:静态代码生成(关键工程决策)
Cloudflare Workers 禁止 eval / new Function,而 protobufjs 反射模式
(Root.fromJSON + 运行期 codegen)恰恰依赖动态代码生成——这是第三方项目
在真机部署时报 Codegen not supported 的根因。
本方案:tools/regen_proto.sh 用 pbjs 从官方 .proto 生成静态模块:
pbjs -t static-module -w esm --es6 --force-long \
--no-create --no-verify --no-convert --no-delimited --no-typeurl --no-service \
-o src/proto_gen.js peer_rpc.proto common.proto error.proto
产物仅含 encode/decode,零运行时代码生成;--force-long + long.js 保证
int64(transaction_id、session_id、peer_route_id)精度。
4.3 握手(对标 peer_manager.rs::handle_handshake)
请求 HandshakeRequest{ magic, my_peer_id, version, features[], network_name, network_secret_digest }:
magic != 0xd1e1a5e1或version != 1→ 关闭连接;- peer_id 非法(0 / 保留 ID / 与服务端相同)→ 关闭;
- 摘要校验:
- 配置了
NETWORK_SECRETS→ 用 SipHash-1-3(siphash.js,与官方NetworkIdentity::digest()相同的算法与密钥派生)计算期望摘要并比对; STRICT_DIGEST=1→ 同网络名已有不同摘要 → 拒绝(防抢占);- 否则按
网络名:摘要建立分组;
- 配置了
- 响应:镜像客户端 features(客户端宣告
liveness-echo-v1时回显之, 否则宣告SERVER_FEATURES),network_name填服务端名(与客户端不同 ⇒ 外部网络中继模式,官方公共服务器同款行为); - 立即推送全量路由(
SyncRouteInfoRequest),并增量广播给组内其他成员。
4.4 RPC 层(对标 rpc_types + rpc/)
分帧:ZCPacket(payload = RpcPacket{ body = RpcRequest{ request = <inner> } }),
响应用 RpcResponse{ response = <inner> } 包裹,transaction_id(int64)原样回传。
descriptor 三元组语义(对标 service_registry.rs::ServiceKey,实测校正):
domain_name = 客户端网络名(服务按 domain 注册)
service_name = "OspfRouteRpc" / "PeerCenterRpc"
proto_name = prost 服务短名,与 service_name 相同(如 "OspfRouteRpc")
这是本项目实测发现的最隐蔽兼容性坑:早期实现把 proto_name 写成
"peer_rpc"(.proto 文件名),与官方 prost 生成的服务短名不符,导致客户端按 ServiceKey 查不到服务、SyncRouteInfo 全部超时。本实现入站兼容两种取值, 出站统一使用官方语义。
method_index 为 1-based(对标 easytier-proto/build/rpc.rs:Method::new((i + 1) as u8)):
OspfRouteRpc.SyncRouteInfo = 1PeerCenterRpc.ReportPeers = 1,GetGlobalPeerMap = 2
压缩:本端不启用(algo=None);收到压缩请求仅记录不处理(当前协商路径不会出现)。
4.5 路由同步(对标 peer_ospf_route.rs)
入站 SyncRouteInfoRequest 处理:
- 解码
my_peer_id / my_session_id / is_initiator / peer_infos.items[] / conn_info; - 记录会话(
dst_session_id),响应SyncRouteInfoResponse{ is_initiator: !req.is_initiator, session_id: 本端会话 }(角色取反为官方语义); - 逐条合并
RoutePeerInfo,合并规则(官方语义):peer_route_id变化 = 新路由实例(节点重启),无条件替换(处理版本回退);- 否则
version只增不减;
- 回推本端路由视图。
出站推送(全量/增量):
- 新连接 / 会话重置 → 全量;否则仅推送
version高于对端已知版本的条目; RouteConnBitmap星型拓扑:服务端与每个已握手 peer 相连,bitmap位图编码;- wire 层重组(
wire.js):对端上报过的RoutePeerInfo保留原始字节, 推送时逐字节搬运。这保留了官方未来新增字段(未知 field number)的透传能力, 对标官方route_peer_wire.rs的 raw_peer_infos 机制;对象路径仅作兜底。
广播时机:新 peer 加入 / peer 离开 / 收到新 RoutePeerInfo / ReportPeers 变化。
4.6 PeerCenter(对标 peers/peer_center/)
ReportPeers(my_peer_id, {direct_peers: {pid: {latency_ms}}}): 整体替换该上报方的条目(官方server.rs::report_peers语义),TTL 180s 自动过期;GetGlobalPeerMap(digest):digest 与服务端一致 → 回空响应(客户端保留缓存), 否则回全量GlobalPeerMap+ 新 digest;- digest:SHA-256(排序键+延迟) 前 8 字节 → u64。
(官方用
DefaultHasher,两端无需一致——digest 只用于"变没变"的自比较,客户端总是接受服务端给的值。)
4.7 心跳(对标 ping/pong + liveness-echo)
- 入站
Ping→ 回Pong,payload 原样回显,from/to 交换;客户端 Ping 带自增 seq,故不能用setWebSocketAutoResponse模板应答(该 API 仅支持固定字节匹配); - 客户端宣告
liveness-echo-v1(features 镜像确认后)会在业务包上带LIVENESS_ECHOflag——本实现把这些包同样视为活性信号(每条消息都会_touch,天然覆盖); - 服务端主动探活:空闲超阈值发 Ping,半开连接因写失败触发 close 事件被立即清理。
5. 纯 P2P 模式
两层设计(均可配置):
AVOID_RELAY_DATA=1(默认):服务端在握手响应后的路由信息中携带feature_flag.avoid_relay_data = true。客户端 OSPF 据此给"经本节点中继"的边 赋大代价——P2P 直连可达时绝不走中继。这正对标官方 v2.6.3 引入的disable_relay_data哲学(控制面在,数据面绕行)。RELAY_DATA:兜底开关。1(默认)= P2P 失败的流量仍经本节点转发 (可用性优先);0= 严格纯 P2P,丢弃 Data/Kcp 数据包只保留控制面 (成本/合规优先)。
6. 幽灵节点六重防线
幽灵节点有两类:
- 连接级:客户端异常退出(断电/杀进程/NAT 静默丢弃)后残留的"假在线"节点—— 第三方实现最被诟病的问题(icesoulhanxi-ws-relay 的核心痛点);
- 路由级:客户端多 PeerId 竞争后"被放弃的 PeerId"作为 transit 条目被其他节点转述上报, 在路由表中留下无连接的"幽灵条目"(用户实测发现的重大 bug:实际在线只有一个连接, 路由表却出现多个无节点在线信息的自己)。
六道防线(a–c 治连接级,d–f 治路由级):
| 防线 | 机制 | 参数 | 延迟 |
|---|---|---|---|
| a. 握手超时 | 连接建立后未完成握手的 socket 直接关闭 | HANDSHAKE_TIMEOUT_MS=15s |
≤15s |
| b. 空闲超时 | 任何消息(含 ping/业务包/liveness-echo)都刷新 lastSeen;超时未刷新即清理并广播路由 | PEER_IDLE_TIMEOUT_MS=75s(> 客户端 ping 间隔上限 32s 的安全余量) |
≤75s |
| c. 主动探活 | 空闲超过 SERVER_PING_IDLE_MS=40s 服务端发 Ping;半开连接的写失败会立刻触发 close 事件 → 立即清理(不等超时) |
SERVER_PING_IDLE_MS=40s |
秒级 |
| d. direct 覆盖规则 + reporter 追踪 | 条目按来源标记 direct(自报)/transit(他报);transit 永不覆盖 direct(即使版本更高——防路由投毒/防幽灵复活);节点(重)连时清除名下遗留条目;上报者断开时其上报的无主 transit 条目立即清除(唯一来源消失即失效) | — | 立即 |
| e. 路由老化 | 对齐官方 clear_expired_peer 两层语义:条目未刷新且节点不可达(无连接且 PeerCenter 无可达路径)超时 → 删除;未刷新超过 TTL → 无条件删除 |
ROUTE_INFO_UNREACHABLE_MS=90s / ROUTE_INFO_TTL_MS=3660s |
≤90s / ≤61min |
| f. 空分组自动删除 | 分组内在线节点归零持续超过宽限期 → 整组删除(含路由条目与摘要注册,防孤儿数据) | GROUP_AUTO_DELETE_MS=60s |
≤60s |
清理动作统一走 _cleanupPeer / cleanupExpiredRouteInfos / autoDeleteEmptyGroups:
从分组移除 → 标记脏数据 → 广播增量路由(其余客户端的拓扑立即收敛)→ 落盘。
防线 d/e 的官方依据:easytier-core 的
clear_expired_peer(PeerCenter 不可达即删除) 与peer_ospf_route.rs的来源可信度模型。本实现在此基础上补充了 reporter 追踪 (官方无此问题——官方服务端是 OSPF 全节点,条目天然有连接背书)。
6.1 资源滥用防线(v1.6.0)
针对第三方动态审计确认的滥用向量(洪泛/放大/状态膨胀/顶替缺陷),在处理 途中加同步检查。零额度开销原则:不新增请求路径、KV 键、定时器与打点 频率,全部防线为 O(1) 内存检查;上限经 env 可配(0 = 关闭),默认值对 合法客户端(房间 64 / 分组 32,见部署手册 §5.3)留数倍余量。
| 防线 | 参数 | 语义 |
|---|---|---|
| 帧条目上限 | MAX_SYNC_ITEMS=256 |
单帧 SyncRouteInfo 条目超限 → 整帧拒绝(4009) |
| 条目字节上限 | MAX_ROUTE_INFO_BYTES=768 |
单条 RoutePeerInfo 超限 → 丢弃该条(防巨型条目入库) |
| 分组总量上限 | MAX_ROUTES_PER_GROUP=128 |
分组路由条目总数闸门(transit/幽灵),兼作出站全量推送体积上界 |
| 直连表上限 | MAX_DIRECT_PEERS_REPORT=64 |
ReportPeers 单上报者截断(PeerCenter 响应体积分量) |
| 出站硬闸 | MAX_MESSAGE_BYTES |
_send 出站单帧校验(与入站对称),超限丢弃并计数 |
| 全量重推冷却 | FULL_RESYNC_COOLDOWN_MS=1000 |
会话重置与 GetGlobalPeerMap 全量响应每连接 ≥1s 一次;戳随 attachment 持久化,休眠唤醒不失效 |
| 摘要抢占可观测 | MAX_NETWORK_NAME_BYTES=128 |
空名/超长名拒绝(4002);抢占嫌疑写 squat-suspect 审计事件(网络名+IP 10 分钟去重),总览计数可见 |
上限自洽不变量:MAX_ROUTES_PER_GROUP × MAX_ROUTE_INFO_BYTES < MAX_MESSAGE_BYTES —— 合法全量路由推送永不触发出站
硬闸;PeerCenter 快照同理(64 上报者 × 64 条 × ~15B)。审计记录按帧聚合:
单帧新增条目 >16 时合并为一条 add-batch 记录,洪泛不挤爆记录环、不放大
KV 写入。
同组互信边界:同一网络分组内的成员共享 network_secret 派生的摘要,
协议层面互为可信对等点(官方 EasyTier 语义)——组内成员可得知彼此 peerId、
可发起同 peerId 顶替(官方客户端重连即依赖该语义)。中继的防线边界在
组外:未持密钥者无法进入分组、无法观测组内拓扑;抢占(堵门)与
顶替(踢人)两类组内/组缘风险分别以「NETWORK_SECRETS 服务端校验 +
squat-suspect 可观测」与「文档明示边界」处置,不做不兼容的协议改造。
7. WebSocket Hibernation 与成本设计
第三方实现(teleseon 等)用 setInterval 心跳维持 DO 常驻——DO 永不休眠,
duration 费用 24×7 计费,免费额度一天就爆。
本实现:
state.acceptWebSocket(ws)(Hibernation API)+webSocketMessage/Close/Error事件处理器;- 全程零 setInterval/setTimeout:清扫用
state.storage.setAlarm()(alarm 唤醒不计 duration 费用的常驻部分,仅按一次请求计费); - alarm 自适应排程(v1.4.0):重挂间隔由
_nextAlarmDelay()计算——有连接时取min(SWEEP_INTERVAL_MS, 最早的 socket 事件时刻)(未握手超时截止 / 探活触发 / 空闲超时截止,下限 1s),完全空闲(无连接且无脏数据/审计待刷)时退避到SWEEP_IDLE_INTERVAL_MS(默认 5 分钟,空房间 5,760 → 288 请求/天); _markDirty()闹钟抖动修复(v1.4.0):原版无条件setAlarm(now+2000),与 注释"若当前无近期 alarm"不符——客户端持续抖动重连时(握手/清理事件间隔 <2s) 闹钟被无限推迟,清扫/老化/落盘停摆(alarm 饥饿);且批量上线时产生冗余 storage 写、破坏自适应排程。现先getAlarm(),仅当无闹钟或已有闹钟比目标 更晚时才设置——绝不推迟更早的闹钟;- 元数据存
serializeAttachment():休眠唤醒后恢复 peerId/groupKey/lastSeen; lastSeen双通道:内存值(运行时权威)+ attachment 节流同步(休眠唤醒后兜底);- 结果:空闲网络中 DO 仅被客户端心跳消息与自适应 alarm 唤醒, duration 计费趋近零,免费额度内稳定运行(实测见 §11.3)。
7.1 额度观测与趋势(v1.6.0)
三层数据源,逐层升级、逐层降级安全:
| 层 | 数据源 | 说明 |
|---|---|---|
| ① 机器可读指标 | /metrics?format=prometheus(Worker 层转换) |
复用 METRICS 门禁,src/prometheus.js 纯函数将 DO _stats() JSON 转为 text exposition format(25 指标、无 label、counter 带 _total);默认 JSON 输出不变。v1.6.0 同时补齐 _stats() JSON(config 对齐管理端 20 项白名单 + sockets/audit 概览) |
| ② 账号级真实额度 | Cloudflare GraphQL Analytics API | DO 内出站查询(不占入站额度)workersInvocationsAdaptive 当日 UTC 窗口 sum{requests,subrequests,errors};结果缓存 DO storage quota_cache(成功 5 分钟/失败 1 分钟);三态 source=cloudflare/unconfigured/unavailable 恒 200;CF_API_TOKEN 只存在于 DO 内存,绝不进入响应体/日志/审计 |
| ③ 时序趋势 | Workers Analytics Engine | alarm 内每 ≥60s writeDataPoint(空闲退避时自然稀疏);/internal/trends 走 AE SQL API,24h=15min 桶、7d=1h 桶,SQL 分桶失败回退原始点服务端聚合;查询结果缓存 5 分钟 |
AE 数据点列映射(src/room.js::_maybeWriteDot,SQL 按列号引用勿调换):
| 列 | 含义 |
|---|---|
index1 |
固定 relay(单索引,采样分布一致) |
blob1 |
服务端版本串(低基数) |
double1~8 |
在线节点 / 分组数 / 收包累计 / 发包累计 / 流量累计(字节) / 累计连接 / 协议错误 / alarm 累计 |
计数器随 DO 重启归零属预期:趋势图对收包速率按桶内增量计算、负增量(重启)钳为 0; 在线节点为瞬时 gauge 取桶内均值。打点约 1,440 点/天(免费计划含 100,000 点/天), 打点与查询均为出站/绑定操作,不消耗 100k 入站请求额度。
8. 状态持久化
PeerManager.toPersisted() 序列化(节流落盘:脏标记 + ≥30s 间隔 + 成员变化短 alarm 提前触发):
- 服务端身份(peerId/hostname——重启不变,客户端无需重新学习路由实例);
- 各分组的
RoutePeerInfo(含 raw 字节)与版本号; - 条目来源元数据(infoSource=direct/transit、reporters 集合、emptySince)—— 重启后幽灵防线 d/e/f 的判定依据不丢失(持久化格式 v4);
- digest 注册表(STRICT_DIGEST 判定依据);
- 会话表。
loadPersisted() 对损坏数据(字段缺失/类型错误)静默丢弃并继续——
DO 存储异常不应导致整个房间不可用。
9. KV 审计与管理端黑名单
实现模块:src/audit.js(AuditStore),由 RelayRoom DO 持有(this.audit)。
9.1 键值布局(每类信息只占一条 KV 键)
键前缀(v1.4.1 起为项目名
easytier-cf-relay:,此前为et-relay:): 首次冷启动时若新前缀键完全为空,自动回读旧前缀键完成一次性迁移 (数据随正常刷盘写入新键);新旧前缀绝不混用,旧键保留不删(确认运行 正常后可在 Cloudflare 控制台手动清理)。
| 键 | 内容 | 说明 |
|---|---|---|
easytier-cf-relay:rec:groups |
网络分组事件 | create / delete(cause=admin/auto/digest-delete) |
easytier-cf-relay:rec:peers |
节点事件 | join / leave / replace(含 IP)。拒绝(reject)不记录——被拉黑客户端会不断重连,逐条记录会刷爆列表并浪费写入额度,改由 blRejected 计数器观测 |
easytier-cf-relay:rec:routes |
路由条目事件 | add(direct/transit,含"顶替 transit")/ remove(含原因) |
easytier-cf-relay:rec:peercenter |
全局互联事件 | add / remove(admin / TTL expire) |
easytier-cf-relay:rec:sockets |
连接事件 | open / close / error(含 socketId、客户端 IP) |
easytier-cf-relay:rec:digests |
摘要注册事件 | register / delete |
easytier-cf-relay:rec:admin |
管理端审计(硬设置) | login(IP/时间,同一 IP 10 分钟去重)/ op(操作明细)。查看(view)不记录(v1.3.0:管理页 10s 自动刷新会刷屏且徒增写入) |
easytier-cf-relay:bl:peer |
节点黑名单 | 值为 PeerId;踢出节点时自动加入 |
easytier-cf-relay:bl:group |
分组黑名单 | 值为网络名;删除分组时自动加入 |
easytier-cf-relay:bl:digest |
摘要黑名单 | 值为网络名;删除摘要注册时自动加入 |
easytier-cf-relay:bl:socket |
连接黑名单 | 值为客户端 IP;断开连接时自动加入。Worker 入口边缘拦截只读这一条键(见 9.4) |
共 11 条键,全生命周期不变。记录类为定长环形缓冲(默认上限 100 条/类,
管理页「记录设置」可单独调整);黑名单上限为硬设置(BLACKLIST_LIMIT,默认 1000)。
记录查询支持 type=all:七类记录在内存合并(每条附 type 字段),按全局 id
(全局单调递增 = 时间序)倒序分页——不引入任何新 KV 键;all 模式删除按 id
跨类型匹配,admin 硬记录永不删除。
9.2 三级写入路径(免费额度成本控制)
事件发生 ──► 内存数组(零成本)
│ alarm 周期(15s):脏即写 DO storage(SQLite,防休眠丢失)
▼
KV 镜像 ──► 仅脏键、按 RECORD_FLUSH_MS(默认 10 分钟)节流;
管理端操作完成后强制刷(黑名单立即生效)
KV 免费额度 1000 写/天:默认间隔下每键理论最大 144 写/天;实际只在有新事件的
刷写周期产生写入,典型小网络全类合计每天数十次。读取仅发生在冷启动且 DO storage
为空时(每键一次,用于恢复)。v1.3.0 起两类高频事件不再产生写入:查看事件不记录
(原先管理页 10s 自动刷新每 5 分钟折叠一次)、黑名单拒绝不记录(重连风暴下原先
每秒可产生一条),拒绝改由 blRejected 内存计数器观测(零写入)。
9.3 硬设置与软设置
- 硬设置(wrangler.toml,管理页不可改):
ADMIN_AUDIT(管理端审计开关)、ADMIN_AUDIT_LIMIT(其上限)、BLACKLIST_LIMIT、RECORD_FLUSH_MS、RECORD_DEFAULT_LIMIT。 admin 类记录在管理页不可删除(防审计自擦除); - 软设置(管理页「记录设置」):六类数据记录各自的开/关与条数上限
(存 DO storage
audit_state,运行时生效,收紧上限时立即裁剪旧记录)。
9.4 黑名单拦截(双层,免费额度优化)
| 时机 | 检查类别 | 行为 | 成本 |
|---|---|---|---|
| Worker 入口边缘层(WebSocket 升级前,v1.3.0) | socket(IP) | 读 bl:socket 一条键(v1.4.0 起 isolate 缓存,默认 30s),命中 403 + Retry-After: 60 |
不唤醒 DO:被拉黑客户端的重连风暴只消耗 Worker 请求额度,不消耗 DO 请求与时长计费;KV 读每 TTL 至多 1 次 |
| DO 升级层 | socket(IP) | 403 拒绝升级(边缘层的权威兜底,覆盖 KV 最终一致窗口内的新拉黑 IP) | 1 次 DO 请求 |
| DO 升级层(事前限流,v1.4.1) | 并发(IP) | 同一 IP 开放连接数 ≥ MAX_CONNS_PER_IP(默认 6)时 429 + Retry-After: 60——黑名单之外的第二道升级闸门,攻击者无需触发任何警报即可被事前拦住;计数进 counters.ipLimited |
1 次 DO 请求(本就要到达 DO 的升级) |
| DO 消息层(速率限流,v1.5) | 速率(连接) | 单连接每秒消息数 > MSG_RATE_LIMIT_PER_SEC(默认 50)时断开(4008 rate limited)——防单个合法成员高频发消息持续唤醒 DO 消耗额度;计数进 counters.rateLimited |
0(本就要处理的消息) |
| DO 握手层(分组限流,v1.5) | 容量(分组) | 单分组在线节点数 ≥ MAX_PEERS_PER_GROUP(默认 32)时拒绝新节点(4030 group full)——防单网络占满整个房间;计数进 counters.groupLimited;同 peerId 重连顶替不受限 |
1 次 DO 请求(握手本就到达) |
| DO 握手层(摘要校验前) | peer / group / digest | 关闭连接(4013 blacklisted)。digest 类 v1.4.2 起支持「网络名:摘要」完整粒度(删分组/删摘要注册写入),并回退匹配纯网络名(存量数据/手工拉黑) | 1 次 DO 请求 |
内部端点防伪造(v1.4.1):Worker 入口会把带
Upgrade: websocket头的 任意路径请求原样转发到 DO,而/internal/*端点自身不鉴权(鉴权在入口完成)。 DO 现对「Upgrade 头 + 内部路径」的组合直接 404——堵住「伪造升级头绕过ADMIN_TOKEN/METRICS_TOKEN直达内部端点(读统计/删记录)」的绕过路径 (升级请求必须原样转发,入口层无法改写副本,故统一在 DO 层拦截)。 另:未知路径的非升级请求在 Worker 入口被 tar pit(TARPIT_UNKNOWN, 挂起不响应,不泄露 404 指纹;EasyTier 客户端只走升级不受影响)。
边缘层要点:
CF-Connecting-IP由 Cloudflare 边缘注入,客户端不可伪造(生产环境);- KV 为最终一致(约 1 分钟):新拉黑的 IP 在窗口内由 DO 层兜底拦截; 解除封锁后至多约 1 分钟内恢复可连(本地 dev 的 miniflare KV 为即时一致);
- KV 读失败时放行走 DO 权威检查(边缘 fail-open、DO fail-closed);
- isolate 缓存(v1.4.0,
IP_BLOCK_CACHE_MS,默认 30s,0 关闭):TTL 内的 WS 升级复用缓存的bl:socket解析结果——只延后拦截、绝不漏放,缓存未命中 (含 KV 读失败)照旧放行走 DO 权威检查。代价仅是新拉黑 IP 的边缘拦截至多 延后「KV 最终一致窗口(约 1 分钟)+ TTL」,窗口内由 DO 层兜底拦截; - 未绑定
AUDIT_KV时边缘层自动跳过,仅 DO 层拦截(功能不变); - KV 读为「每 TTL 至多 1 次 + 冷启动」量级(免费额度 10 万读/天,重连风暴时 也不再与重连次数成正比)。
各层拒绝均不写记录,只递增 counters.blRejected(DO 层,总览页「黑名单拦截」
可见;边缘层拒绝不经过 DO,不计数,可在 Cloudflare 分析中查看)。
管理操作自动拉黑(v1.4.2 起粒度与操作对象对齐):踢出节点 → peer 类;删除分组 → digest 类(完整「网络名:摘要」,抢占者被精确拦截、正主不受影响);删除摘要注册 → digest 类(被删注册的完整粒度);断开连接 → socket 类(已知 IP 时)。 存量 group 类数据与手工添加的纯网络名格式继续生效(digest 类回退匹配)。 黑名单页支持手工添加、单个/批量移除(解除封锁)与清空。
RPC 源绑定(v1.4.2,外部审查修复):全部 RPC(请求与响应、无论目标是 服务端还是经转发的其它节点)统一校验——未握手连接不处理;包头
from_peer_id必须与连接注册身份一致,否则丢弃并计入counters.forgeries。修复前 server-destined 分支不经过转发路径的身份校验,存在冒名自报覆盖路由的盲区。
9.5 事件来源(挂钩点)
- 连接/节点/分组/摘要事件:room.js 生命周期路径(
_acceptSocket/webSocketClose/_handleHandshake/_cleanupPeer); - 路由与互联事件:PeerManager 纯逻辑层通过可选
onEvent钩子发射 (route-add/route-remove/group-remove/pc-report/pc-remove, 带原因标签:peer-left / reporter-gone / expire / admin / reconnect), room 层转发给AuditStore——纯逻辑层不依赖审计,单测不受影响; - 管理端审计:Worker 入口将
CF-Connecting-IP注入x-admin-ip转发给 DO, DO 在内部端点记录登录(同一 IP 10 分钟去重)与操作(action 明细); 查看(view)不记录(v1.3.0)。
9.6 审计数据最小化(v1.6.0)
两项独立开关(均默认关闭,配置见部署手册 §5.6):
AUDIT_IP_MASK:record()写入时对ev.ip打码——IPv4 保留 /16 前缀 (1.2.3.4→1.2.x.x,含 IPv4-mapped IPv6)、IPv6 保留前 4 组。 仅影响记录展示:黑名单存储、踢人拉黑(使用连接上的完整ws._clientIp)、 边缘层 KV 拦截均不受影响;旧记录不回填。AUDIT_RETENTION_DAYS:alarm 内至多每小时一次调用audit.pruneExpired(now),按记录ts过滤超过 N 天的条目;仅清理 6 类 运行记录(admin 硬审计豁免),清理后标记脏键随下一轮 flush 同步 DO storage 与 KV 镜像。空闲期 alarm 退避至 300s 不影响清理粒度 (节流锚点为 1h)。
10. 第三方项目问题与本实现的修复
| 项目 | 主要问题 | 本实现对策 |
|---|---|---|
| teleseon/cf-workers-et-ws | setInterval 心跳导致 DO 永不休眠(持续计费);protobufjs 反射模式在 Workers 上触发 eval 限制 | 全 Hibernation + alarm;pbjs 静态代码生成 |
| icesoulhanxi-ws-relay | 幽灵节点:无握手超时、无空闲超时、无主动探活;WS_PATH 运算符优先级 bug('/'+env.WS_PATH || '/ws');不存在的 setWebSocketAutoTimestamp API |
三重防线;接受任意路径;attachment 元数据 |
| 21paradox / pikachuim 等 | PacketType 枚举值抄错(RelayHandshake=16 等);proto 字段 camelCase/snake_case 混用;repeated 字段描述符写法错误;method_index 0-based | 逐字段对照官方源码修正 + 单元测试锁定 |
| (本项目自查实测发现) | proto_name 写成 "peer_rpc"(.proto 文件名)而非 prost 服务短名 → 真实客户端 SyncRouteInfo 全部 20s 超时(自测协议测试却通过——测试数据和实现犯了同一个错) |
出站统一服务短名;入站双兼容;引入真实官方客户端 E2E 防止协议自洽假阳性 |
经验教训已固化进测试策略:协议实现的自测必须包含真实官方二进制, 自造协议数据跑通不能证明兼容性。
11. 测试体系与实测结果
11.1 静态检查
node --check全部源文件语法通过;wrangler deploy --dry-run构建通过(bundle ~644 KiB / gzip ~88 KiB,远低于 10MB 限额)。
11.3 真实客户端实测(easytier-core v2.6.4 官方 release 二进制)
环境:wrangler dev(local mode)+ 两个官方客户端进程。
| 验证项 | 结果 |
|---|---|
| 握手 + 路由同步 | ✅ easytier-cli peer 显示 easytier-cf-relay / easytier-cf-relay/1.0.0 / ws / p2p,延迟 ~2-4ms |
| 双节点互见 | ✅ nodeA 与 nodeB 经中继互见(relay(2),path_len=2),路由表完整 |
| 数据中继 | ✅ /metrics forwards 持续增长,errors=0 |
| 长稳运行 | ✅ 数分钟运行后延迟 1.52ms、0 丢包、连接无中断 |
| 异常路径 | ✅ 错误 magic 连接被关闭;digest 不匹配被拒绝 |
复现步骤:
npx wrangler dev --port 8787
easytier-core --network-name t --network-secret s --hostname a --no-tun --no-listener \
-i 10.126.0.1 --rpc-portal 127.0.0.1:15888 -p ws://<host>:8787/
easytier-core --network-name t --network-secret s --hostname b --no-tun --no-listener \
-i 10.126.0.2 --rpc-portal 127.0.0.1:15889 -p ws://<host>:8787/
easytier-cli --rpc-portal 127.0.0.1:15888 peer # 应见 easytier-cf-relay 与 nodeB
12. 已知限制
| 限制 | 说明 | 影响 |
|---|---|---|
| 不支持 secure mode(Noise) | v2.6.0+ 的可选加密握手。收到 NoiseHandshakeMsg 包会关闭连接(4003) | 客户端使用默认(非 secure)模式即可 |
| 不执行 P2P 打洞 | Workers 无 UDP 出口。打洞由客户端之间直接进行(本节点提供 rendezvous/路由信息) | 双端均严格 NAT 时只能走中继 |
| 不做 KCP/QUIC 协议转换 | KcpSrc/KcpDst/Quic* 包仅按 to_peer_id 转发 | 无(这些包本来就是端到端的) |
| 不压缩 RPC | 出站 algo=None | 微量带宽差异,无功能影响 |
| 单房间串行(DO 单线程) | 单 DO 软限制 ~1000 req/s;更大规模需房间分片(ROOM_HEADER/QUERY) | 64 peer 默认上限内无感知 |
| 同组内 peerId 可被顶替 | EasyTier 协议固有语义(重连顶替),组内成员互信(共享网络密钥) | 顶替路径状态一致性已修复(先清后注);组间风险由分组隔离与 NETWORK_SECRETS 防守 |
未配置 NETWORK_SECRETS 的抢占风险 |
先到者注册摘要后,不同密钥的后来者被拒(STRICT_DIGEST 隔离语义的另一面) | squat-suspect 审计可见攻击 IP;删除分组/摘要注册可解锁;配置 NETWORK_SECRETS 即免疫(注册表自愈) |
| IPv6 子网信息 | RoutePeerInfo 原样透传(raw 字节路径保留官方字段),但服务端自身不宣告 v6 | 客户端间 v6 组网不受影响 |
13. 代码结构参考
easytier-cf-relay/
├── src/
│ ├── index.js # Worker 入口:/health、/metrics 与管理端(自定义安全路径+token)、WS 升级、房间路由
│ ├── room.js # RelayRoom DO:连接生命周期、握手、转发、alarm 清扫、管理端内部端点、审计挂接
│ ├── peer_manager.js # 分组/peer 表/路由信息(direct/transit 来源)/会话/持久化(纯逻辑,可单测;onEvent 钩子)
│ ├── audit.js # KV 审计:六类事件记录 + 管理端硬审计 + 四类黑名单(每类一条 KV 键)+ IP 打码/留存清理(v1.6)
│ ├── prometheus.js # Prometheus 文本格式输出(_stats JSON → text exposition,v1.6)
│ ├── rpc.js # RPC 编解码 + OspfRouteRpc/PeerCenterRpc 处理(含身份校验)
│ ├── admin_ui.js # Web 管理端单页(浅色默认 + 明暗/主色/圆角可定制 + 可收缩侧栏 + 总览瀑布流(趋势全宽双轴/卡片收起记忆/板块显隐)+ 设置与帮助模块 + 移动适配,API 全鉴权,v1.6 翻新)
│ ├── assets.js # 内嵌 favicon PNG
│ ├── proto.js # protobuf 类型入口(Long 配置、短名映射)
│ ├── proto_gen.js # pbjs 静态生成(勿手改;regen 见 tools/)
│ ├── wire.js # protobuf wire 层:未知字段保留、RoutePeerInfo 原字节重组
│ ├── packet.js # ZCPacket 16B 头解析/构造/转发改写
│ ├── siphash.js # SipHash-1-3(network_secret 摘要)+ hex/随机字节
│ └── constants.js # 协议常量(PacketType/flags/MAGIC…)
├── tools/
│ ├── regen_proto.sh # 从官方 .proto 重新生成 proto_gen.js
│ ├── proto_filter.json
│ └── make_release.mjs # 发行流程脚本(白名单复制/清理规则/禁词扫描/--check)
├── wrangler.toml # 部署配置(变量说明见部署手册)
└── docs/ # 技术文档 / 部署手册 / 更新日志 / 客户端设置兼容性
协议对齐依据(官方源码,tag v2.6.4):
easytier-core/src/packet/mod.rs、peers/peer_manager.rs、peers/peer_ospf_route.rs、
peers/peer_center/{server.rs,instance.rs}、rpc/service_registry.rs、
easytier-proto/proto/{peer_rpc,common,error}.proto、easytier-proto/build/rpc.rs。