软件 1.0.21。协议 1.0(loft/1.0)。规范:protocol_zh.md。运营:guide_zh.md。
本文描述本仓库的 Go 树。第三方实现可以换目录结构,照样走协议。
参考节点是一个二进制 loft-node,两个角色:控制器和中继。参考客户端是 loft(CLI:SOCKS5 和可选 TUN)。loft-front 是同一条会话,没有改系统路由的 CLI 标志;macOS 菜单栏应用把它当助手拉起。loft-probe 经会话查 ICMP/TCP/UDP/DNS/HTTP。共用代码在 internal/。
docs/ 协议、架构、指南
internal/
wire/ 帧, Hello, Status, 名册, 列表, 机群, caps, 码
crypto/ HKDF, ChaCha20-Poly1305, auth 0x02 + X25519, Ed25519 seed
identity/ 访问密钥 lt_ + 48 hex; SHA-256 在 network.conf; 明文在 members.keys
mux/ 流, 反压, CmdDgram, CmdLeave
netconf/ network.conf, 租约, ACL, VLAN, DNS, 会话定时器
transport/ 前置, 诱饵, 伪装, 中继干线, 覆盖网, p2p, DNS 桩, 拉名册
config/ client.yaml, server.yaml
cdn/ 边缘配置(Yandex, Cloudflare);不是 caps。运营方:docs/yandex-cdn_zh.md
version/ 程序名, semver, 协议范围
server/
cmd/loft-node
origin/ 掩护页, GET, WebSocket, HTTP/2 CONNECT, 公开名册/列表
configs/ server.yaml, relay.yaml
debian/ 安装脚本, systemd unit, .deb
panel/ loft-panel 网页面板(规划与原型:panel/design)
clients/
go/cmd/loft CLI:SOCKS5, TUN, kill-switch
go/cmd/loft-front 会话助手(macOS bundle;Aurora RPM libexec)
go/cmd/loft-probe 覆盖网 ICMP/TCP/UDP/DNS/HTTP,不改系统路由
go/tools/wsprobe 一次性 GET 握手(手动;不进 dist)
macOS/Sources Swift 5 + AppKit 菜单栏;拉起 loft-front
macOS/pack DMG(Applications 符号链接)、签名、entitlements
macOS/design UX 规范和模型
macOS/tests RFC 8439 AEAD 向量(make -C clients/macOS test-crypto)
Keenetic/ Entware OpkgTun 目录(方案和模型;还没有二进制)
iOS/ Packet Tunnel(薄宿主 + appex;gomobile xcframework;Apple TV 在 PLAN 第 6 阶段)
Android/ VpnService(薄 Java 宿主;gomobile AAR;UX 模型;Android TV / Google TV 在 PLAN 第 4 阶段)
Aurora/ ConnMan 覆盖网(QML 宿主 + loft-connman 插件 + loft-front)
mobile/ iOS/Android 共用 Go 引擎(loft, SSTP, VLESS)
brand/ 标志和图标
wire 是协议。transport 是这棵树怎么扛它。cdn 是部署配置:某个命名边缘的头顺序和 SNI。线上它不能当能力出现。
成员会话:拨号、auth blob、Status,然后 mux。
client.Holder
Supervise(cfg) 轮换出站和前置
Open(target) get | ws | h2 | quic | h3
TLS 1.3 (uTLS)
auth 0x02 (nonce || eph_c || HMAC)
Hello MemberCaps(没有 CapRelay;loft-node 中继上有 ExtraCaps)
eph_n || AEAD(Status) // 1.0.16 客户端:HMAC(lt_, nonce),只 Seal,0x03 ticket;1.1 去掉
mux Open / Data / Close / Dgram / Leave
select: auto 时,Supervise 按列表顺序试配置好的出站。第一次 Status 之后只留前置的交集。失败就换下一条还在的前置,再试未签名的 replicas,再试签过的名册。
WatchPath 大约一秒采四次默认 IPv4 出口。Wi-Fi / LTE / VPN 地址一变,就把活着的前置丢掉,让 Supervise 用新的 0x02 和同一个 instance_id 续上。SOCKS5 和 TUN 还绑着(Holder);覆盖网 IP 不变。有 cap hold 时,mux 流在 gap_hold_ms(默认 8 秒)里还在,里面的 TCP 不 RST。GET 的 X-Auth 按 instance_id 捡起停着的 mux,跟 WS 一样(新的 HTTP sid,同一条 mux)。loft 和 loft-front 都跑这个循环。
拉名册要 controller_pub。tofu: true 钉住第一个有效签名者,写到 {instanceDir}/controller.pub。公钥空着又没这个标志,名册就不用;静态出站照样拨。
里面 AEAD 的 nonce 对每个会话密钥唯一(32 位前缀 + 64 位计数器)。用同一把密钥 park,计数器接着走。新的 0x02 从 X25519 派生新密钥;HKDF 的 ikm 是 DH 密钥。
客户端不选诱饵 SNI。Status、名册或探测证书给。CDN 出站保持 CDN 主机名。
本机入站(SOCKS5、TUN)调 Holder.Open(host:port) 或发数据报。覆盖网目的地由节点上的 NetSwitch 解析。Status dns 是解析器 IP 列表;节点用 dns.upstream(DoH、DoT 或 UDP)答 gateway:53。Status 尾巴 ip6 / gateway6 可选,没有就是只有 IPv4。
存着:
network.conf 里是 SHA-256;明文在 {dir}/members.keys(keep_keys 让运营方能再读这个文件)。auth shared enable 只在密钥环空着时才把 LOFT_TOKEN 当客户端密钥。{dir}/controller.seed可以给成员听 443。中继干线必须听。
签名 seed 第一次启动时创建。公钥给客户端当 controller_pub。参考客户端不会 TOFU 未知签名者,除非 tofu: true。
终结公网 TLS。用 Hello cap relay 向控制器开一条干线。这条会话上:
ctl:1 — enrol、beat、slice、drainhub:1(HTTP/1.1)或 hub:2(HTTP/2)中继把 TLS 之后的 HTTP 拷到 hub:1 / hub:2。里面的 mux AEAD 和 FinishAuth 留在控制器。中继不解覆盖网帧。不发租约。不存成员密钥。覆盖网 IP 在控制器上分配。
中继 IP 用完就扔。从名册拿掉会 bump serial。已经拿到 Status 的客户端会话接着跑,直到失败,再拉新名册。
server/origin 是公网路径看到的 HTTPS 应用:
cover_dir,或内置页)诱饵拼接的探测到不了这个处理:HTTP 之前就转到配置的源站。
systemd unit、安装脚本和 .deb:server/debian/README_zh.md。令牌在 /etc/loft/loft.env。覆盖网文档在 /var/lib/loft。进程用户 loft,绑 443 用 CAP_NET_BIND_SERVICE。
transport.WrapDecoy 偷看 TCP ClientHello。
Accept
read ClientHello(或剩下的字节)
if valid session_id and own SNI
节点证书(advertise 上的成员,sid 之后的 CDN)
else if own SNI from cdn_peers
节点证书(CDN 拉源站)
else if session_id = sid v3(节点 X25519)or sid v2(成员 HMAC)
if mask ready → dest 形状的 ECDSA 叶子 + 记录填充
else → loft HTTP 处理
else
TCP 拼接到诱饵源站(SNI 匹配,否则主源)
UDP
decoy on → 数据报拼接到 dest
decoy off → loft QUIC/H3
拼接到哪是节点配置。客户端自己发现。YAML 里的主机名是例子,不是协议常量。
伪装签发一张 ECDSA P-256 叶子,抄源站身份,用 ecdsa_secp256r1_sha256 签 CertificateVerify。序列号绑在和节点 X25519 的 ECDH 上。探测照样拼接。应用记录可以跟源站密文长度。
transport.Overlay 把每条准入会话绑到一个覆盖网 IP。对网段 CIDR 里面的地址做 Open,按 peers 分类:
| 模式 | 动作 |
|---|---|
| hub | Reset policy_drop |
| mesh | 拼接到对端的 mux 会话,Open 127.0.0.1:port |
| vlan | VLAN 对上就 mesh |
TCP Open 和覆盖网 ICMP 走 Network.Allow(tag、CIDR、VLAN、default deny)。ip route DST VIA|member ID|tag NAME 是成员后面的 CIDR:Open host:port 带着原来的 target 拼到那个成员。Status routes 列出这些 CIDR,via 是解析后的覆盖网 IP;VIA 那位不在名单里。互联网 Open(CIDR 和这些路由外面)不变。直连 P2P 用 Status policy 走同一套 Allow。session ttl_ms 关掉活会话;lease_hold_ms 在断开后占着覆盖网 IP。
接收端客户端接这个 Open,拨 target。覆盖网 IP 拼接是 127.0.0.1:port。Routed 拼接是成员主机栈上的 host:port。
可选 P2P:客户端听着,宣告 loft.peer:<port> 加 32 字节 X25519 公钥,Status 带着 TLS 看到的主机、端口和公钥。直连 TCP 用 42 字节 LFT 前言(和对方公钥做成对 ECDH 再 HMAC),Status 带着 policy 时再走和 hub 拼接同一套 ACL。失败就回 hub 拼接。客户端 p2p: false 就不听。seed 在 {instanceDir}/p2p.seed。
控制器发布有名字的集合。客户端把名字映射到动作(use:)。几张列表都命中时的顺序:drop,然后 direct,然后 via。没命中的默认 via。
SOCKS 匹配请求的主机名。TUN 拿目的 IP 对 CIDR。域名种类对裸 IP 套接字不管用。
Status lists_serial 或 lists_hash 比缓存新时才拉列表。签名和名册同一把 Ed25519。
| 监听 | 默认 | 说明 |
|---|---|---|
| SOCKS5 | 127.0.0.1:1080(GUI / client.yaml);127.0.0.1:18080(loft / loft-front 没写端口时) |
RFC 1928。dgram 开着就有 CONNECT 和 UDP ASSOCIATE。 |
| TUN | 关;tun: auto 建 utun |
gVisor 用户态栈。UDP 走 CmdDgram。ICMP echo:自己本地回;覆盖网和互联网目的地走 Dgram :1(节点发互联网 echo)。Darwin 在 ICMP 上 NAT 假地址 169.254.8.1 ↔ 覆盖网 IP。 |
| kill-switch | listen.kill_switch |
最后一条 TUN 死了,系统默认路由还是撤着,直到用户自己恢复 |
TUN 排除 loft 会话的出站主机(CDN、节点),免得隧道把自己的路吞了。
macOS 菜单栏客户端是 Swift 5 + AppKit,在 clients/macOS/Sources/。UX:clients/macOS/design/DESIGN_zh.md。活着的会话是应用包里的 loft-front(Contents/MacOS/loft-front),不是进程内的 GETFront.swift。这个助手的 Hello 是 Go 客户端(GET 和 WS)。设计文档里的路由保持、故障切换和分流列表是机器策略,坐在 Go CLI 标志上面。
会话死了之后试的顺序:
select: auto 顺序里下一条出站。replicas。重连定时器来自 Status:reconnect_min_ms、reconnect_max_ms、reconnect_fast_n、reconnect_fast_for_ms。先快试,再指数退避。
副本接新的 0x02。Hello instance_id 保住覆盖网租约。
Mux Pull 先抽 Open/Ping/Pong/Dgram/Leave 和小 Data(≤ 4 KiB),再抽大块 Data/Pad,免得整段下载卡住 ICMP、TCP ACK 和 Cloudflare loaded-ping。Close/Reset 留在 Data 队列,优雅关闭不能跳过剩下的字节。Data 队列 48 帧。WS 预取 2×48 KiB;控制帧自己有 8 KiB 消息。
session rate_bps(网络默认,config member ID rate_bps 覆盖)是该成员会话 bulk CmdData/CmdDgram 的令牌桶,双向。0 为关。控制和小帧不整形。Status rate_bps 宣告上限;节点执行。中继干线不限。
每个成员实例一条 mux 会话。流是带有界接收缓冲的 goroutine。mux 载荷 ≤ 16384;Stream.Write 和 EncodeBatch 切开 CmdData,免得 32 KiB 的 io.Copy 把帧丢掉。
GET 前置有有界 inflight 窗口(协议 2–8,默认 4;这棵树默认 48)。客户端泵是唯一的上行 Puller,按 Pull 顺序发 GET seq。节点按 seq 应用上行;只有按序的 GET 才 Pull mux 下行,免得丢掉的 HTTP 响应把已经从 mux 拿走的帧弄丢(YouTube/Speedtest TLS)。空闲 GET 节奏是 keepalive_ms / 2(下限 1 秒),抖动 ±⅓。Open/很小的 Data 等 15 ms,数据报等 80 ms,好让 ICMP 搭同一条 body。这棵树通用 GET header_budget 是 16 KiB HTTP 头(base64url+AEAD 之后解码 mux 大约 12 KiB,Cookie Z85 之后大约 13 KiB);Yandex 配置钉死 16 KiB encoded。Status h2_conns(节点 get_h2,1–4)是额外的 HTTP/2 连接,当第二个 header-budget 信封;inflight 还是总数。上行已经有 ≥ 2 KiB CmdData 的 GET 把 body 限制在 96 KiB,好让 Cookie 槽回收;只下载还是 256 KiB。
中继干线:每个中继进程一条 mux 会话,很多 hub:* 流。
Supervise 每个客户端进程一个循环。多个 YAML 配置(macOS 多连接)是多个进程或多个 Holder。那不是 cap multi。cap multi 是一把成员密钥上多个 instance_id。
虚拟机、公网 DNS、公网证书、二进制更新,都不在这个协议里。systemd unit、POSIX 安装脚本和 .deb 在 server/debian/。线上走 enrol、beat、slice、drain、名册和列表。运营方签发每成员密钥,写 network.conf 和 members.keys。