loft 是覆盖网,在线路上占的位置和普通 HTTPS 一样。线协议是 loft/1.0。
协议 1.0。 第三方按本文实现,就能和 loft 节点对话。本仓库的 Go 树是其中一种实现。许可 Apache-2.0。
软件版本(VERSION,现在 1.0.21)和协议版本无关。握手两边都带。关键词 MUST、SHOULD、MAY 按 RFC 2119。Hello 和 Status 尾巴上不认识的字段 MUST 忽略。
覆盖网要是握手长得特别,或者目的地址独一无二,真实路径上就不稳:CDN、反向代理、TLS 中间盒等的是普通 HTTPS。loft 在线路上占的位置和普通 HTTPS 一样:443 上的 TLS 1.3,浏览器模样的 ClientHello,应用数据 CDN 可以终结。里面的流做多路复用,再用 ChaCha20-Poly1305 封住。节点 MAY 出示真实 HTTPS 源站的身份;没带访问密钥的探测拼接到这个源站,扫描器和健康检查看到的是网站。覆盖网身份(密钥、租约、活着的前置名单)住在控制器上。中继是可换的 TLS 边缘。客户端拉一份签过名的前置名册和有名字的地址列表,再把这些列表对上本机路由。同一网络的成员 MAY 在 hub 上隔离、经节点做 mesh,或允许直接打 TCP。典型部署:从外面进实验室局域网、管一机群、站点互联。
加密隧道在生产路径上会以两种方式挂掉。握手长得特别,TLS 中间盒或 CDN 等的是浏览器,直接丢。隧道打向独一无二的目的 IP,地址不在 allowlist 上,也丢。对实验室或远程接入的覆盖网,这两种都是运营故障。
loft 把公网路径做成普通 HTTPS。里面的协议就一套。承载它的路径(GET、WebSocket、HTTP/2 CONNECT、QUIC、HTTP/3 CONNECT)按部署选,MAY 改而不升协议版本。客户端发出的 TLS 名字由节点定。没有访问密钥的探测,和配置好的源站完成握手。
第二笔账是运营。地址到不了了,只要客户端已经知道另一条前置、覆盖网地址还是同一个,换起来便宜。控制器签一份前置名册。中继登记、发心跳,沉默了就出名册。访问密钥从不出现在这份文档里。
本文规定线协议。参考节点怎么编译、怎么运营,见 guide_zh.md。参考树怎么摆,见 architecture_zh.md。
application TCP / UDP (SOCKS 和 TUN 是本机的)
mux Open Data Close Reset Ping Pong Pad Dgram Leave
AEAD ChaCha20-Poly1305
front get | ws | h2 | quic | h3
TLS 1.3
TCP/443 or UDP/443
TLS ClientHello SHOULD 做成当下浏览器那样(密码套件、扩展、JA3/JA4)。CDN 厂商名是部署配置。它们 MUST NOT 出现在协议能力里。
会话有三层:
中继干线上预留的 mux 目标:hub:1(HTTP/1.1)、hub:2(HTTP/2)、ctl:1(机群控制)。覆盖网宣告:loft.peer:<port>。
Hello、Status 和多数机群记录的字符串字段是 u8 length || bytes,最多 64 字节。URL 和列表值是 u16be length || bytes,最多 255 字节,除非某节另有说明。整数是大端。
节点在 Status caps 里宣告开着的前置。客户端在 Hello caps 里宣告它会用的前置。第一次 Status 之后,客户端 MUST 只用交集,按自己配置的顺序,当前这条挂了 MUST 切到剩下的下一条前置。
两边都开了全部前置时,默认尝试顺序:get、ws、h2、quic、h3。
loft 的 ClientHello 有 32 字节 session_id。节点两种构造都收,先试 v3。
sid v3。 令 P 为节点的静态 X25519 私钥(节点密钥的 HKDF-SHA256,info "loft-mask-x25519"),X 为 ClientHello 的 X25519 key_share,R 为 32 字节 client_random,S 为小写 SNI:
shared = X25519(P, X)
session_id = HKDF-SHA256(ikm=shared, salt=R, info="loft-sid-v3" || 0x00 || S, L=32)
对应公钥是 Status / 名册 mask_pub(32 字节),MAY 出现在分享链接 query pbk。要标出这类 ClientHello,得有 P。偷来的访问密钥标不出来。
sid v2(回退)。 令 token 为访问密钥:
HKDF-SHA256(ikm=token, salt=R, info="loft-sid-v2" || 0x00 || S || X, L=32)
客户端还没拿到 mask_pub 时发 v2。有 token 就能认出 v2 ClientHello。绑在 R、S、X 上,HMAC(token, random) 那种配方对不上。
别的 TCP ClientHello 拼接到节点配好的源站。第一个源站是节点的身份。节点源站里 SNI 对上的就用那个;否则用主源站。源站主机名是配置。它们 MUST NOT 作为协议常量出现。
没有有效 session_id 时,这个字段是 32 字节串,形状和 Chrome 的兼容 session_id 一样。直连路径上客户端 MUST 把节点的主诱饵主机名当 SNI 发出。这个名字从 Status、名册,或探测会看到的证书里学到。Status 之后对不上 MUST 用宣告的名字重连。CDN 前置继续用 CDN 主机名。
伪装开着时,loft ClientHello 完成 TLS 1.3 时戴着 dest 形状的证书:ECDSA P-256 叶子,抄源站的 Subject、SAN 和有效期。叶子序列号是
HKDF-SHA256(ikm=shared, salt=R, info="loft-cert-serial", L=16)
shared 是 sid v3 的 ECDH。CertificateVerify 是这张叶子上的 ecdsa_secp256r1_sha256。知道 mask_pub 的客户端 MUST 核序列号绑定和 ECDSA 验签。应用记录 SHOULD 填充,让密文长度跟着从源站采到的样式走,包括采过的头几条之后。Status mask_pub 和名册 mask_pub 带着 32 字节 X25519 公钥。空表示伪装关着。探测照样拼接。只做拼接的实现,和只拼接的节点仍然兼容。
ws)TLS,然后 HTTP upgrade。路径是节点本地的(默认 /api/v1/sync)。客户端第一条消息是明文 auth blob。节点第一条是 AEAD(Status)。后面是 AEAD(mux 批次)。空批次 MUST NOT 发送。关掉套接字就结束会话。客户端 SHOULD 在关之前在 mux 批次里发 CmdLeave。
get)给只转发 GET 的边缘。HTTP/2。路径长得像资源:{base}/{session}/{seq}.js。
第一个 GET:头 X-Auth: 1,Cookie session=<base64url(auth blob)>。响应 200,X-Session,Cache-Control: private, no-store,体是 AEAD(Status),AAD = 会话 id。
后续 GET:X-Request-Id: base64url(AEAD(mux batch)),AAD = 会话 id,体是 AEAD(mux 批次)。节点 SHOULD 在体前面加 CmdReport(applied = 最后一段连续收下的 GET seq)。客户端收到了 MUST 停止重试上行 seq ≤ applied。节点 MAY 也在 Cookie session= 里收这条上行(老客户端);那时没有 X-Auth: 1。认证失败 MUST 回 404 带 HTML。上行 MUST 塞进一个头(Status header_budget 是编码后 Cookie 字节;省略时默认 16 KiB HTTP 头)。Status uplink_enc 1 是 ~ 加上 u16be length || AEAD blob 的 Z85(RFC 32);0 和省略是 base64url。握手 Cookie 仍是 base64url。节点 MUST 两种都收。Status h2_conns(1–4)是到这条前置的 HTTP/2 连接数;0 保持一条。Inflight 是这些连接上未完成 GET 的总数。HTTP 413:预算减半,下限 1024,再拆开。HTTP 405:这条前置不是 GET。Inflight 2–8(协议默认 4;Status inflight 是上限)。客户端看到 HTTP/2 GOAWAY ENHANCE_YOUR_CALM MUST 在同一会话上退避,MAY 在下一条已经带着 mux 的 GET 上发 CmdTune event 1。节点 MAY 降低这条前置的 header_budget / inflight,并在这个 GET 体里回 CmdTune event 0。空闲时上行空着:至少等 keepalive_ms / 2(且不少于 1 秒)。已经带着 mux Data、Open、Dgram 或 Leave 的 GET 不算空闲。节点 SHOULD 为下行把 GET 最多攥 250 ms,MUST NOT 攥超过 1 秒。上行里已经有 CmdData 时,节点 MAY 一下行 Data 入队就回。这条上行有 ≥ 2 KiB CmdData 时,节点 SHOULD 把这个 GET 体封顶 96 KiB,好让下一条 Cookie 快点转出去;纯下载的 GET 仍是 256 KiB(多条流时 192 KiB)。
HTTP/2 断了但没 Leave,会话不结束。客户端进程退出前 MUST 再发一个 GET,上行批次里带着 CmdLeave。节点于是关掉 mux 会话,忘掉 GET 会话 id,Detach,Release。lease_hold 仍为 ok_resumed 留着覆盖网 IP。
h2)ALPN h2。CONNECT 到节点本地路径(默认 /api/v1/tunnel)。200,然后消息 u32be length || bytes:auth、AEAD(Status)、AEAD(mux 批次)。
quic)客户端发起的第一条双向流,长度前缀消息和 h2 一样。ALPN MUST 是 h3。如果这个 UDP 口上也开了 HTTP/3 CONNECT,CONNECT 拿走 ALPN,这条 mux 不服务。
h3)ALPN h3。CONNECT 路径形状和 h2 一样。
Status MAY 置 canary_flags:bit 0 胖 ClientHello,bit 1 ClientHello 拆成两条 TLS 记录(节点拼回来)。canary_pad 是额外填充字节,0–2048。
成员出示浏览器模样的 ClientHello,CDN、反向代理、TLS 中间盒看到的是普通 HTTPS。中间盒要是改写了握手,会话就起不来。这是路径完整性:节点不收被改坏的 TLS。它不向会重组记录的检查器隐瞒 SNI。
connect)GET mux 在请求/响应上轮询一套流协议。节点 MAY 在 Status 里置能力 connect,仅当 fronts 列出 connect(默认关)。常规 GET/WS 握手(租约、覆盖网 IP)之后,客户端 MAY 用 HTTP/2 CONNECT :authority host:port 打开 TCP,而不是 mux CmdOpen。该 CONNECT MUST 带与 GET 握手相同的 0x02 Cookie(Cookie: session= base64url,X-Auth: 1)。证明失败为 HTTP 404。节点拨那个目标并把字节拼上。它 MUST 拒绝 loopback、link-local(含 169.254.169.254)、multicast、unspecified 和 localhost。覆盖网成员 IP、UDP、ICMP 仍走 mux。没有 connect 的节点,每条 TCP 都走 mux。未开此前置的 HTTP CONNECT MUST 404。
访问密钥是 lt_ 加 48 个十六进制字符(24 字节)。节点存 SHA-256(key)。部署 MAY 所有成员共用一个令牌;那是配置。
0x02 || nonce[16] || eph_c[32] || HMAC-SHA256(key, "lft-a2" || nonce || eph_c)[0:16] || Hello
eph_c 是客户端的临时 X25519 公钥。重放一个新 nonce 是新握手。同一个 nonce||eph_c 落在节点时钟 10 分钟缓存里,是幂等重试(GET Cookie);同一个 nonce 换了 eph_c 就拒。客户端时钟不用。
节点 MAY 仍收 1.0.16 的证明 0x02 || nonce[16] || HMAC-SHA256(key, nonce)[0:16] || Hello,会话密钥 HKDF(key, nonce),第一条消息 Seal(Status) 不带 eph_n,以及 0x03 续上票据。参考节点在整个 1.0.x 软件里这么做。本修订的客户端 MUST 发 4.1 的证明。1.1 里丢掉 1.0.16 这条路。
换路径或进程重启发一条新的 0x02,新临时密钥,同一个 Hello instance_id。Hold 按已认证成员加 instance 把停着的 mux 再挂上。副本故障切换是在新节点上发 0x02;覆盖网 IP 跟着 lease_hold 和 instance。Status MAY 仍带空的 ticket 字段(长度 0)。
ss = X25519(sk_c, eph_n)
HKDF-SHA256(ikm=ss, salt=nonce, info="loft-v1", L=32)
节点第一条消息是 eph_n[32] || Seal(Status)。后面的 mux 消息只 Seal。eph_n 是节点这次握手的临时 X25519 公钥。HMAC 盖住 eph_c,所以偷来的 Cookie 组不成会话:AEAD 密钥需要 sk_c,它没离开过客户端。
封装:nonce[12] || ChaCha20-Poly1305(key, nonce, plaintext, aad)(RFC 8439)。Nonce MUST 对每个会话密钥唯一。参考实现用 32 位随机前缀加 64 位计数器,好让停着的 GET 会话接着计数。GET 用 AAD = 会话 id;其他前置用空 AAD,除非某节另有说明。
AEAD 盖住 mux。TLS 盖住到终结器的路径。终结 TLS 的 CDN 看到的是 mux 的密文。
Hello 接在 auth blob 后面。节点第一条消息是 eph_n[32] || Seal(Status)。Status 是 AEAD 明文。Hello 和 Status 都不是 mux 命令。
u8 version (= 1)
u8 member_id_len || member_id
u8 instance_id_len || instance_id
u16be proto_min
u16be proto_max
u64be caps
u8 program_len || program
u8 software_len || software
instance_id 存在客户端上。空表示节点在 Status 里发一个;客户端 MUST 存下来。字符串 ≤ 64 字节。协议版本是 major<<8|minor(0x0100 = 1.0)。能力 relay 标记中继干线。能力 p2p 标记会宣告监听端口的客户端。
没有 proto/caps 的老 blob 仍能解。尾巴上不认识的字节 MUST 忽略。
字段按顺序:
| 字段 | 编码 |
|---|---|
| code | u16be |
| lease_sec | u32be |
| mtu | u16be |
| vlan | u16be |
| keepalive_ms, idle_ms, reconnect_min_ms, reconnect_max_ms | 各 u32be |
| reconnect_fast_n | u16be |
| ip, gateway, instance_id | u8 len || bytes |
| reconnect_fast_for_ms | u32be |
| selected proto, min, max | 各 u16be |
| caps | u64be |
| program, software | u8 len || bytes |
| ticket | u16be len || bytes |
| decoy | u8 n,然后 n × (u8 len || bytes) |
| mask_pub | u8 len || bytes(0 或 32) |
| replicas | u8 n,然后 n × (u8 len || bytes) |
| canary_flags | u8 |
| canary_pad | u16be |
| roster | u16be len || bytes |
| lists_serial | u64be |
| lists_hash | u8 len || bytes(0 或 32) |
| peers | u8(0 hub,1 mesh,2 vlan) |
| p2p | u8(1 = 节点允许) |
| peer hints | u8 n ≤ 16,然后 n × (ip, host, port u16be, vlan u16be) |
| dns | u8 n ≤ 8,然后 n × (u8 len || bytes) — 给客户端 TUN 的解析器 IPv4/IPv6。空:客户端 MUST 不动系统 DNS。 |
| ip6, gateway6 | 可选尾巴:各 u8 len || bytes。两个都空时整段省略(只有 IPv4)。不读它们的解码器 MUST 忽略尾巴字节。 |
| header_budget, inflight | ip6/gateway6 之后可选:u16be 编码后 GET Cookie 预算,u8 inflight。有一个非零就出现(IPv4 时 ip6/gateway6 是空串)。握手值是上限;活着的 CmdTune 说了算。0 = 客户端留自己的。 |
| h2_conns, uplink_enc | inflight 之后可选:u8 HTTP/2 连接数(1–4;0 = 客户端留一条),u8 上行编码(0 base64url,1 Cookie Z85)。有一个非零就出现。老客户端忽略多余字节。 |
| gap_hold_ms | uplink_enc 之后可选:u32be。前置掉了但没 Leave 时,节点把 mux 流攥多久。0 = 客户端默认(8 秒)。老客户端忽略多余字节。 |
| routes | gap_hold_ms 之后可选:u8 n ≤ 16,然后 n × (dst, via),各是 u8 len || bytes。受管 CIDR:成员后面的实验室 LAN(via 是该成员的覆盖网 IP,由 ip route 的 VIA / member ID / tag NAME 解析)。节点把 Open host:port 拼到那个成员上。客户端 MUST NOT 安装 via 等于自己覆盖网 IP 的路由。老客户端忽略多余字节。 |
| policy | routes 之后可选:紧凑 ACL,让客户端在直连 P2P 上走同一套 Allow。u8 n_own_tags 再 tags;u8 acl_default(0 peers/accept,1 drop);u8 n_rules 再规则;u8 n_peers 再覆盖网 IP + tags。空就省略。读到这段的解码器 MUST 置 Present。老客户端忽略多余字节。 |
| session_ttl_ms | policy 之后可选:u32be 活会话上限。0 = 关。跟 lease_sec(断开后占 IP)不是一回事。没有 policy 也要写这个字段时,编码器先写空 policy。老客户端忽略多余字节。 |
| rate_bps | session_ttl_ms 之后可选:u32be 本会话 bulk CmdData/CmdDgram 上限,双向,字节/秒。0 = 关。控制和小帧(Open、Ping、ACK ≤ 4 KiB、DNS/ICMP Dgram ≤ 512 B)不整形。节点执行。没有 session_ttl_ms 也要写时,编码器先写 ttl 0。老客户端忽略多余字节。 |
选定协议是两个范围交集的最大值。交集为空给出码 protocol(0x0203),不开 mux。
| 位 | 名 | 含义 |
|---|---|---|
| 0 | multi | 一把密钥上多个实例 |
| 1 | resume | ok_resumed 和租约保持 |
| 2 | sticky_ip | 密钥上粘滞的覆盖网 IP |
| 3 | vlan | 从密钥来的 VLAN |
| 4 | rules | 交换机 ACL |
| 5 | get | GET 前置 |
| 6 | ws | WebSocket 前置 |
| 7 | status | 带码的 Hello/Status |
| 8 | timers | Status 里的 keepalive / idle / reconnect |
| 9 | hello_caps | 这块能力 |
| 10 | reset | CmdReset |
| 11 | quic | QUIC mux 前置 |
| 12 | dgram | CmdDgram |
| 13 | clockless | 认证不靠客户端时钟 |
| 14 | ticket | Status MAY 带票据 blob(本修订为空) |
| 15 | decoy | Status 列出源站 |
| 16 | h2 | HTTP/2 CONNECT |
| 17 | h3 | HTTP/3 CONNECT |
| 18 | mask | 借用的源站证书 |
| 19 | relay | 本会话是中继干线 |
| 20 | roster | Status 可以带签过名的名册 |
| 21 | lists | Status 可以宣告列表 serial/hash |
| 22 | p2p | mesh 时对等直连 |
| 23 | dns | Status 可以带 DNS 服务器;gateway:53 是解析器 |
| 24 | connect | HTTP/2 CONNECT 拼接:TCP Open 不走内部 mux |
| 25 | resin | CmdReport 和 resin/draft GET 上行 |
| 26 | hold | mux 流撑过短暂前置掉线;用 0x02 和同一成员加 instance_id 续上 |
节点关掉某条前置 MUST 清掉对应位。客户端不会用某条前置 MUST 清掉它。SOCKS、TUN、CDN 厂商、OS、git、源站主机名不是能力。
版本协商:max(intersection(client.min..client.max, node.min..node.max))。Status 里的能力是节点宣告的集合。客户端自己决定用其中哪些。
Status 之后,每段 AEAD 明文是帧的拼接:
u16be payload_length 0..16384
u8 cmd
u32be stream_id
bytes payload
客户端流标识是奇数,从 1 起。节点的是偶数,从 2 起。活着的标识 MUST NOT 复用。Close 或 Reset 之后 MAY 再用。
| cmd | 名 | 载荷 |
|---|---|---|
| 1 | Open | 目标 |
| 2 | Data | 字节 |
| 3 | Close | 空 |
| 4 | Ping | 8 字节,原样回 |
| 5 | Pong | 那 8 字节 |
| 6 | Pad | 忽略 |
| 7 | Reset | u16be code |
| 8 | Dgram | Open 目标前缀,然后 UDP 载荷 |
| 9 | Leave | 空;stream_id MUST 是 0 |
| 10 | Tune | 4 字节:u8 event \|\| u8 inflight \|\| u16be header_budget,可选第 5 字节 h2_conns。客户端 event ≠ 0 是一次前置中断(1 ENHANCE_YOUR_CALM,2 HTTP 413,3 HTTP 404,4 连接重置,5 HTTP 429,6 其他 GOAWAY),在下一条已经带着 mux 的 GET 上发一次。节点 event 0 设定活着的 GET header_budget / inflight / h2_conns(字段里 0 = 不变)。节点只在这些值变了才回。stream_id MUST 是 0。 |
| 11 | Report | u64be applied(节点收下的最高连续 GET seq)。stream_id MUST 是 0。老客户端忽略不认识的 mux 命令。 |
mux 帧是 resin 或 draft。Resin 攥着,直到 Report 盖住它的 GET seq,然后在同一个 seq 上重传:Open、Ping、Pong、Leave、Tune、Report、Close、Reset,以及打到 53 端口的 Dgram。Draft 只发一次(Data、Pad、其他 Dgram)。里面的 TCP 或 UDP 如果应用还要这些字节,会再产一帧。一次 mux Pull 最多变成三个 GET seq:控制 resin、draft Data,然后 Close/Reset。Close MUST NOT 和同一条流的 Data 共用 Cookie。HTTP 出错之后,客户端用纯 resin 重试同一个 seq(Cookie 只有 draft 时用 Pad),把 seq 洞补上,draft 不重放。第一次尝试时若干 inflight GET 收到 HTTP 404 是边缘噪声;同一个 sid 上三次重试 404 表示源站丢了会话。活着的 stream_id 上重复 Open MUST 忽略。
Open 目标:u16be host_len || host || u16be port。Dgram 载荷是这个前缀加上数据报。
Leave 结束 mux 会话。stream_id 是 0。载荷为空。节点 MUST 关掉会话,Detach 覆盖网对端,Release 租约(Online=false)。lease_hold 仍为同一个 instance_id 留着 IP。同一批次里 Leave 后面的帧 MAY 忽略。还不会处理 Leave 的实现 MUST 忽略这条命令(不认识的 mux 命令 MUST 忽略,MUST NOT 因此重置会话)。CmdClose 结束一条流;不结束会话。
实现 MUST 把帧打成批次(每段 AEAD 明文大约 1–16 KiB)。缓冲压力下 MUST NOT 丢 Data:要做反压。每流接收缓冲至少 64 KiB,最多 256 KiB。完不成的 Open 是 Reset stream_refused(0x0501)。Ping 间隔是 keepalive_ms。空闲超时是 idle_ms。在 get 上 Pad,让 AEAD 前的明文大约 400–600 字节。
带长度前缀的包装(h2、quic、h3、ctl):u32be length || payload,最多 1 MiB。AEAD 里面的 mux 载荷仍 ≤ 16384。
订户是一把访问密钥。实例是客户端生成、存在本地的标识。空实例:节点在 Status 里发一个;客户端存下来。
同一个实例再连上收到 ok_resumed 和同一个覆盖网 IP。multi 关着时第二个实例收到 auth_already_online。multi 开着,后续实例从池里拿地址,直到 multi_limit(默认 8),然后 auth_limit。密钥上的粘滞 IP 给第一个实例。
断开之后,lease_hold_ms(默认 45 秒)为这个实例留着 IP。Leave 立刻 Release Online;保持只是为续上预订 IP。GET 会话没 Leave 就消失了,仍保持 Online,直到节点下次再见到这个会话。
Hello 和 Status 都宣告 hold 时,意外的前置掉线(换路径、无线空洞)在 gap_hold_ms(默认 8 秒)内 MUST NOT Close mux 流。客户端用新的 0x02 和同一个 instance_id 重拨;节点只把停着的 mux 挂给证明匹配的那位成员。里面的 TCP 不断。gap_hold_ms 内没续上,节点 Close mux。Leave 仍立刻 Close。
客户端 MAY 在已知空洞附近在本地暂停 draft Data(不上线路);resin 照发。
默认值:握手 10 秒,空闲 120 秒,保活 15 秒,租约保持 45 秒,短断开窗 8 秒,重连最小 200 ms,最大 8 秒,8 秒内快试 5 次,然后指数退避。
数值稳定。名字 MAY 增加。号码不改。
| 码 | 名 | 含义 |
|---|---|---|
| 0x0000 | ok | 新租约 |
| 0x0001 | ok_resumed | 同一实例,同一 IP |
| 0x0101 | auth_bad_key | 不认识的密钥 |
| 0x0102 | auth_revoked | 密钥换过或删了 |
| 0x0103 | auth_expired | 加入 TTL 过了 |
| 0x0104 | auth_not_authorized | 成员关掉了 |
| 0x0105 | auth_limit | multi_limit |
| 0x0106 | auth_already_online | multi 关着,另一个实例 |
| 0x0107 | auth_instance | instance id 不对 |
| 0x0108 | auth_clock | 时钟偏差(clockless 时不用) |
| 0x0201 | net_disabled | 网络关了 |
| 0x0202 | net_unknown | 网络不对 |
| 0x0203 | protocol | 没有共同版本 |
| 0x0204 | rate_limit | 握手太多 |
| 0x0205 | timeout | 握手或空闲 |
| 0x0206 | internal | 节点故障 |
| 0x0301 | ip_pool | 池耗尽 |
| 0x0302 | ip_conflict | 粘滞冲突 |
| 0x0303 | vlan_denied | VLAN 不允许 |
| 0x0401 | policy_drop | ACL 或 hub 隔离 |
| 0x0501 | stream_refused | Open 失败 |
| 0x0502 | stream_reset | 对端 Reset |
非 OK 的 Status 禁止 mux。GET 认证上的 HTTP 404 当成 auth_bad_key。
节点加载一份网络文档(CIDR、网关、成员、ACL)。覆盖网 IP 和 VLAN 出现在 Status 里。打向覆盖网 CIDR 内地址的 Open 按 peers 转发:
| peers | Overlay Open |
|---|---|
| hub | Reset policy_drop。打向互联网的 Open 不变。打向成员后面受管 CIDR 的 Open 仍拼接。 |
| mesh | 拼接到对端会话:在那个成员上 Open 127.0.0.1:port。 |
| vlan | 和 mesh 一样,仅当两端同一 VLAN。 |
Hub 拼接:接收端客户端接这条流,拨本机端口。
网络上 p2p 对 mesh 和 vlan 默认开。开着时,Status 带着最多 16 条对端提示(覆盖网 IP、TLS 上看到的主机、监听端口、VLAN、32 字节 X25519 pub)。Hello 带能力 p2p 的客户端听 TCP,用 Open loft.peer:<port> 加上这条流上的 32 字节 X25519 pub 宣告,MAY 先用 42 字节前言拨 host:port,再退到 hub 拼接:
"LFT" || 0x00 || src_ipv4[4] || HMAC-SHA256(ss, "lft" || nonce || dest_port || src_ipv4)[0:16] || nonce[16] || dest_port u16be
ss 是 X25519(local_sk, 提示里的 peer_pub)。两名成员 lt_ 不同,仍然共享这对密钥,HMAC 用它。应答方在自己的提示里查 src_ipv4。没有 pub 或 MAC 对不上就关套接字;发起方走 hub 拼接。p2p: false 的客户端 MUST 只用 hub 拼接。经中继看到的主机是中继自己;直连于是失败,走 hub 拼接。Status 带着 policy 时,发起方和应答方 MUST 在拼本地端口之前走和 hub 拼接同一套 Allow。没有 policy 的 Status(老节点)跳过这项检查。
每条提示记录是 u8 ip | ip | u8 host | host | u16be port | u16be vlan,然后可选 u8 32 | pub[32]。后面一个字节不是 32,解码器 MUST 当成下一个字段(老 blob,或对端还没宣告 pub)。
ACL 规则是先匹配先算,accept 或 drop。规则为空仍应用 peers。包字段:源、目的、源 VLAN、目的 VLAN、源 tag、目的 tag、到网关、routed(目的命中经成员的 ip route)。地址:any、host <IP>、CIDR、tag NAME(group 是同一记号)。ip access-list overlay 下的 default deny 保留网关,其余丢掉,直到 permit 命中。TCP Open 和覆盖网 ICMP 走同一条 Allow。ip route DST 的下一跳是覆盖网 IP、member ID 或 tag NAME(先活着的成员,否则固定 IP)。
覆盖网 ICMP echo 是打到 dst:1(IP 协议 ICMP)的 CmdDgram,载荷是 IPv4 包。节点应答网关地址。其他覆盖网目的按 peers(hub 丢掉成员之间的;mesh/vlan 拼接到那个成员)。打向覆盖网 CIDR 外地址的 echo,节点发成真的 ICMP echo;应答以 CmdDgram session-ip:1 回来。客户端对本机覆盖网 IP 的 echo 在本地应答,MUST NOT 替其他覆盖网或互联网地址伪造应答。节点 MUST 把会话的覆盖网 IP 当成包源(Darwin utun 的假地址 169.254.8.1 会改写)。客户端 MAY 在 echo-reply 上把这个假地址填回去,好让内核对上 ping 套接字。
控制器握着密钥、租约、ACL 和签名。中继终结公网 TLS,把 TLS 之后的 HTTP 拷到干线会话上:Open hub:1 或 hub:2。里面的 mux AEAD 和 FinishAuth 留在控制器;中继不解覆盖网帧,也不存成员密钥。只有带能力 relay 的 Hello MAY Open hub:* 或 ctl:1。成员 MUST NOT 置这一位(它不是前置)。参考客户端 Hello 里不带;角色是中继的 loft-node 经 ExtraCaps 置上。enrol: list 的节点还要求认证过的成员 id 在 relays: 里(Hello 成员 id 为空是共享令牌中继;enrol_req 仍查 id)。能力 hold 和续上 0x02 跟这一位无关。成员认证和覆盖网 IP 仍在控制器上。
client ──TLS──► relay ──trunk (mux hub:1|hub:2, ctl:1)──► controller
▲
访问密钥从不存在中继上
Ed25519 盖在体上:
u64be serial
u8 n
repeat n:
id (u8+bytes)
url (u16be+bytes)
decoy (u8+bytes)
fronts u8 k, k × (u8+bytes), k ≤ 16
drain u8
weight u8 (0 means 100)
path (u8+bytes)
mask_pub u8 len, 0..32 bytes
然后 u8 pub_len || pub || u16be sig_len || sig。签名是体上的 Ed25519。公钥 32 字节。签名 64 字节。
活名册是 advertise(id self)加上在跳心跳、没 drain 的中继。三次没跳到(默认间隔 10 秒)或 ctl 流死了,中继拿掉,serial 加一。
认证后拉取:Status roster。认证前拉取:GET {roster_path}(默认 /static/v3/manifest.json),JSON {"v":1,"p":"<base64url>"},Cache-Control: public, max-age=60。这个 GET 在 CDN 或有真证书的主机上能过。诱饵拼接的 IP 没有会话 HMAC 就转到源站。文档 MUST NOT 含成员令牌。参考客户端要有 controller_pub 才收名册。首次使用信任(tofu: true)是客户端显式打开的旗标,不是默认。
ctl:1)每条消息 u32be length || u8 type || body。
| type | 名 | 体 |
|---|---|---|
| 1 | enrol_req | id, url, decoy, fronts |
| 2 | enrol_ok | 签过名的 slice |
| 3 | enrol_no | u16be code, reason |
| 4 | beat | load u8, conns u32be, program, uptime_s u32be |
| 5 | slice | 签过名的 slice |
| 6 | drain | id, u8 |
Slice(Ed25519 盖在体上):serial、id、url、decoy、fronts、drain、weight、beat_ms u32be、expires unix u64be,然后 pub 和 sig 和名册一样。enrol: list 要求 id 在控制器 relays: 里。enrol: open 收任何认证过的干线。YAML 在设了的时候覆盖 URL、decoy、fronts、weight。Enrol 拒绝:1 不认识的 id,2 格式不对,3 重复,4 关掉了。
机器和证书怎么上线,不在本协议里。
控制器 MAY 发布有名字的地址列表。名字本身在协议里没有含义。客户端把名字对到 direct、via 或 drop。主机落进多个对上的列表时,先 drop,再 direct,再 via。没对上的目的用客户端默认(SHOULD 是 via)。
体(Ed25519,和名册同一把密钥):
u64be serial
u8 nsets
repeat:
name (u8+bytes)
u16be n
repeat n: u8 kind, u16be+bytes value
Kind 1 精确域名,2 后缀,3 CIDR。然后公钥和签名和名册一样。
Status 带着 lists_serial 和签过名 blob 的 SHA-256。serial 或哈希更新时客户端拉 GET {lists_path}(默认 /static/v3/lists.json)。运营方 YAML:前导 . 或 *. 是后缀;CIDR 串是 kind 3。名字匹配不分大小写。SOCKS 对主机名。TUN 对 IP(CIDR)。
这套构造对准的是等着普通 HTTPS 的 CDN、反向代理和 TLS 中间盒。它不是 mixnet。覆盖网 IP、访问密钥、控制器身份是部署的秘密,不是公网的。
ClientHello 做成当下浏览器那样,就能过银行和门户已经在用的同一套 JA3/JA4 allowlist。参考客户端里 loft 用 uTLS 指纹。独特的 ALPN、独特的 GREASE 样式,或 Go 默认密码套件,都是便宜的破绽。所以 QUIC 的 ALPN 是 h3。
session_id 是 32 字节、看着像随机。sid v3 是节点静态 X25519 上 ECDH 再 HKDF;中间盒要标出这类 ClientHello,得有那把私钥。偷来的访问密钥只标得了 sid v2(知道 mask_pub 之前的回退)。两把秘密都没有的中间盒,看到的是 Chrome 已经在发的同样长度的串。
伪装出示 dest 形状的 ECDSA P-256 叶子和 ecdsa_secp256r1_sha256 CertificateVerify,用序列号绑在同一份 ECDH 上。核公有 CA 链的中间盒不会收这张叶子;只看名字、日期和签名方案的中间盒,看到的是 dest 该有的值。loft 路径上的应用记录长度 SHOULD 跟着从源站采到的样式走。只做拼接的节点不用伪装。
只跑 loft 的 IP 是单点故障。CDN 主机名,或已经在跑公开站点的源站,才是打算用的前置。中继存在,是为了地址可以出名册,成员身份仍留在控制器上。
诱饵拼接的探测看到源站。把诱饵主机名或源站 IP 撤掉,是路径的运营决定,不是协议常量。
扫描器打开 TLS 但没有有效 session_id,拼接到配置好的源站,包括自身 SNI(CT / 反向 DNS 主机名)、空 SNI,以及第一趟不是 TLS ClientHello 的。它拿到源站活着的证书和字节。这个 IP 上的 UDP 转到同一个源站,所以 QUIC 探测看到 dest,不是 loft。组成 sid v3 需要 mask_pub;覆盖网 Hello 仍要访问密钥。例外是 CDN origin pull:配了的 cdn_peers CIDR 可以在自身 SNI 上用节点证书完成 TLS,不需要 sid。
没有有效 Cookie 的 GET 回 404 带 HTML。掩护路径(名册、列表、静态资源)是普通 GET。
认证 0x02 是 nonce、临时 X25519 pub,以及访问密钥下的 HMAC。同一个 nonce||eph_c 落在缓存 TTL 里是幂等重试。新 nonce 是新握手。客户端时钟不用,所以时钟跑偏的笔记本照样能进。副本收新的 0x02;它们不共享这份缓存。
签过名的文档是公开的。谁能 GET 到 CDN 路径,谁就能读活着的前置。这和公开网站是同一块面。访问密钥不在里面。钉死 controller_pub 的客户端拒掉被换掉的密钥。首次使用信任是显式打开的(tofu);参考客户端默认不钉不认识的签名者。
Hub 隔离对成员之间的 Open 发 Reset policy_drop。Mesh 经节点拼接。P2P 是可选的 TCP 尝试,用 TLS 上看到的主机;NAT 和中继会让这次尝试失败,然后走 hub 拼接。直连 P2P 用 Status 里的 policy,和节点同一套 ACL。控制器握着成员密钥哈希、租约和覆盖网地址。其他成员 Open 某个对端时才知道它的覆盖网 IP(mesh/vlan)。p2p 开着时,Status 对端提示还带着 TLS 上看到的主机、监听端口和 X25519 pub。直连 TCP 用 LFT 认证(ECDH 对密钥)。session_ttl_ms 是活会话上限;lease_sec 是断开后占 IP。同一 instance_id 的新 0x02 重新计 TTL。
中间盒已经有访问密钥时怎么跑。每个不认识的 IP 都被过滤、没有 allowlist 时怎么跑。TLS 终结器手里还有覆盖网内部密钥时怎么跑。这些不在协议里。
| 限制 | 值 |
|---|---|
| mux 载荷 | ≤ 16384 |
| Hello/Status 字符串 | ≤ 64 |
| 名册 URL | ≤ 255 |
| 同时未完成的 GET | ≤ 8 |
| 每会话流数 | SHOULD ≤ 256 |
| AEAD 批次 | ≤ 48 KiB(ws),≤ 头预算(get) |
| GET 掩护明文 | AEAD 前大约 400 字节 |
| 带长度前缀的包装 | ≤ 1 MiB |
| 对端提示 | ≤ 16 |
| 名册条目 | ≤ 255 |
| 每条目前置数 | ≤ 16 |
| nonce 缓存 | TTL 10 分钟;超过 50 000 条就清 |
| canary_pad | 0–2048 |
| gap_hold_ms | 0–600000(默认 8000) |
| rate_bps | 0 = 关;否则字节/秒,u32 |
协议 1.0 在第一次发布前保持 1.0。升 major:帧格式或认证不兼容。升 minor:新能力;老客户端仍能说。握手选两个范围里最高的版本。
软件版本是 Hello 和 Status 上的字符串。git 哈希省略。线路上有程序名和 semver 就够。
客户端 MUST 至少实现 get 或 ws、mux 和认证。要走 CDN 或 TLS 中间盒,或跑在 mesh 网络里的客户端,名册拉取、诱饵、伪装、列表和覆盖网转发都要有。
Data,流 1,载荷 hi:
00 02 02 00 00 00 01 68 69
Open example.com:443(CmdOpen 的载荷):
00 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d 01 bb
Reset stream_refused:载荷 05 01。
访问密钥形状:lt_ + 48 个小写十六进制。整串的 SHA-256 是节点存的。
里面的协议不大:一套 AEAD,一条 mux,稳定的码,签过名的名册,有名字的地址列表,加上覆盖网策略。花样推到路径上(前置、诱饵源站、中继 IP),运营方换边缘不用给成员重新编号。成员身份和覆盖网地址还在。