单线程 · 零依赖 · 静态二进制 One thread · no dependencies · static binary
给老路由器写的
REALITY 客户端
A REALITY client for
the router you own
TLS 1.3、REALITY 认证、VLESS + XTLS Vision,全部从零实现。一个线程、一个 epoll 循环,内存在启动时算好,不随流量增长。 TLS 1.3, REALITY authentication, VLESS + XTLS Vision — all written from scratch. One thread, one epoll loop, memory decided at startup and never growing with traffic.
它解决的问题The problem it solves
这类机器的三个硬约束互相咬合:慢核、小内存、没有硬件加密。xray-core 在这台机器上 VSZ 超过 32 MB 的 ulimit,起不来;trojan-go 能跑但吃不消。zray 是把这三条约束当成设计前提重写了一遍,而不是把通用客户端塞进去。 Three hardware constraints interlock on a box like this: a slow core, little RAM, no hardware crypto. On the reference router xray-core exceeds a 32 MB VSZ ulimit and never starts. zray treats those constraints as the design brief instead of squeezing a general-purpose client through them.
慢核A slow core
单核软算 AES-GCM 的上限就在 1.5 MB/s 左右。所以必须让大流量离开外层 TLS:XTLS Vision 认出内层 TLS 握手后切成直接拷贝,稳态下几乎不再加解密。 Software AES-GCM on this core tops out near 1.5 MB/s, so bulk traffic has to leave the outer TLS. XTLS Vision spots the inner TLS handshake and switches to direct copy — at steady state it barely encrypts anything.
小内存Little RAM
128 MB 的路由器可用内存只有 20 MB 上下。连接槽在启动时一次性分配,之后既不增长也不碎片化,超出就干净地拒绝新连接。 A 128 MB router has maybe 20 MB free. Connection slots are allocated once at startup — no growth, no fragmentation — and new connections are refused cleanly when they run out.
无浮点、无依赖No FPU, no deps
MT7621 没有 FPU,固件也常常不带 CA 证书链。除了 Zig 标准库没有任何依赖,静态 musl 二进制拷进去就能跑,不用装包、不用带一套动态库。 The MT7621 has no FPU, and router firmware rarely ships a CA bundle. There is nothing beyond the Zig standard library: drop in the static musl binary and run, no packages, no shared objects.
做了什么What is inside
协议栈是自己写的,所以可以只为这一条路径优化:没有插件系统,没有通用抽象层,也没有为了兼容别的协议而存在的代码。下面不是功能清单,是一条连接从 socket 向内走的六层剖面。 The protocol stack is hand-written, which is what makes it optimisable for exactly one path: no plugin system, no generic abstraction layers, no code that exists to support other protocols. What follows is not a feature list — it is one connection in cross-section, from the socket inward.
非阻塞 socket,单线程 epoll 循环。每条连接的状态机都挂在这里,没有线程池,也没有后台线程可以甩锅。 Non-blocking sockets on one epoll loop. Every connection's state machine lives here; there is no thread pool and no worker thread to blame.
记录层与密钥调度是自己写的:SHA-256 / SHA-384 两条路径、逐记录 nonce,与 RFC 8448 的测试向量逐字节对照——不只是「能连上」。 Record layer and key schedule written by hand: SHA-256 and SHA-384, per-record nonces, checked byte-for-byte against the RFC 8448 vectors — not just "it connects".
15 个 Chrome 套件、GREASE、GREASE ECH、ALPS、X25519MLKEM768,扩展顺序逐连接打乱,JA4 的 _b / _c 段与当前 Chrome 一致。REALITY 的 auth key 就在这同一个握手里派生,session_id 被 AEAD 封装,证书再用 HMAC-SHA512 反向校验——整条链路上唯一一处「证明对方是谁」的地方。
15 Chrome suites, GREASE, GREASE ECH, ALPS, X25519MLKEM768, extension order shuffled per connection, JA4 _b / _c matching current Chrome. The REALITY auth key is derived inside this same handshake: session_id sealed with AEAD, the certificate checked back with HMAC-SHA512 — the one place in the chain where anyone proves who they are.
uuid、目标地址、flow 三个字段。目标是域名还是 IP,从这里开始走两套逻辑。 Three fields: uuid, target address, flow. From here on, a name and a literal IP take different paths.
填充状态机认准内层 TLS 1.3 握手,之后双向切成直接拷贝:外层 TLS 在稳态不再碰你的字节。这颗核软算 AES-GCM 只到 1.5 MB/s,能跑到 9–10 MB/s 全靠这一层。 The padding state machine spots the inner TLS 1.3 handshake and switches both directions to direct copy: at steady state the outer TLS stops touching your bytes. Software AES-GCM on this core manages 1.5 MB/s; the 9–10 MB/s comes from this layer.
客户端的首包在上游握手还没结束时就被解出了 SNI 或 Host,所以服务端看到的是域名。UDP 另走 XUDP 帧:DNS 通,QUIC 一般不通。
The client's first bytes are read for SNI or Host while the upstream handshake is still in flight, so the server sees a name. UDP rides XUDP frames separately: DNS works, QUIC generally does not.
两个入口Two inbounds
SOCKS5(CONNECT 与 UDP ASSOCIATE)和 netfilter REDIRECT(SO_ORIGINAL_DST)。入口没有认证,所以它只该活在回环上。
SOCKS5 (CONNECT and UDP ASSOCIATE) and a netfilter REDIRECT inbound via SO_ORIGINAL_DST. No authentication on the inbound, so it belongs on loopback.
出口可以拐弯Exits can bend
节点可以骑 ws,也可以经 dialer 串成链;domain_suffix 把域名分给一组出口,故障只在组内切换,绝不退回默认线路。
A node can ride ws or be chained through a dialer; domain_suffix hands names to a group of exits that fails over only within itself, never back to the default route.
占用是可算的Footprint you can compute
槽位池启动时一次性分配,没有 GC、没有后台线程。总占用就写在启动那行日志里:N slots × 93 KiB。
The slot pool is allocated once at startup; no GC, no background threads. The whole footprint is on the startup line: N slots × 93 KiB.
从设备到网站只经过一层 TLS:外层是伪装成访问 sni 的 REALITY,稳态下大流量直接透传;服务端只知道是谁在访问,网站只知道服务端的 IP。
One TLS layer from device to site: the outside is REALITY dressed up as a visit to sni, and at steady state bulk traffic passes straight through. The server learns what you visit; the site only learns the server's IP.
实测数字Measured numbers
全部来自小米 AC2100(MT7621,MIPS 1004Kc 单核,软浮点,ImmortalWrt 23.05),隧道对端 RTT 约 110 ms。同一台机器不走隧道、WAN 直连只有 1.4 MB/s —— 慢的是国际出口,不是 CPU。 Everything below is from a Xiaomi AC2100 (MT7621, one MIPS 1004Kc core, soft-float, ImmortalWrt 23.05), upstream at ~110 ms RTT. Fetched over the WAN without any tunnel, the same box does 1.4 MB/s: the international path is the slow part, not the CPU.
单节点,拉取 50 MBOne node, 50 MB download
| 路径Path | 吞吐Throughput | CPU(单核)CPU, one core |
|---|---|---|
| HTTPS,Vision 已切到直接拷贝HTTPS, Vision switched to direct copy | 9–10 MB/s | ~17 % |
| HTTP,留在外层 TLS 内(软算 AES-GCM)HTTP, stuck inside outer TLS (sw AES-GCM) | 1.5 MB/s | ~98 % |
| 3 路 HTTPS 并发3 parallel HTTPS streams | 10 MB/s 合计aggregate | ~55 % |
WebSocket 与链式的代价What WebSocket and chaining cost
| 路径Path | 吞吐Throughput | 每 MB 的 CPU 时间CPU per MB |
|---|---|---|
| 单个 REALITY + Vision 节点one REALITY + Vision node | 5–7 MB/s | 0.03–0.05 s |
| 链式,relay 和 exit 都是 Visionchain, Vision on both legs | 1.2–6 MB/s | 0.10–0.15 s ~3× |
单个 network: "ws" 节点(无 Vision)one network: "ws" node (no Vision) |
~0.5 MB/s | 0.65–1.0 s 15–30× |
| 链式,Vision relay → ws exitchain, Vision relay → ws exit | ~0.55 MB/s | ~1.0 s |
ws 出口整条连接都要软件加解密,单核上限就在 1–1.5 MB/s,适合 AI 聊天这类小流量;default 应该留在 Vision 节点上。另外这类机器有一个内核参数比任何 zray 选项都重要:net.ipv4.tcp_rmem 的默认上限约 930 KB,把单流卡在「窗口 / RTT」上,调到 4 MB 之后上表的 HTTPS 数字才成立。
A ws exit keeps the outer TLS for the whole connection, so one core caps out at 1–1.5 MB/s — fine for chat-sized traffic, not for video; keep default on a Vision node. One kernel knob matters more than any zray option here: the default net.ipv4.tcp_rmem ceiling (~930 KB) pins a single stream to window / RTT, and the HTTPS numbers above only hold once it is raised to about 4 MB.
装上它Install it
每个 Release 都带五个静态 musl 二进制和对应的 sha256,没有动态依赖。选错架构是唯一常见的坑:uname -m 在 MIPS 上分不出大小端。
Every release carries five static musl binaries with their sha256 sums — no dynamic dependencies. Picking the wrong architecture is the one common trap: uname -m cannot tell MIPS endianness apart.
| 文件File | 适用Target |
|---|---|
zray-mipsel-softfloat | 小端 MIPS,多数 MT7621 / MT7620 路由器LE MIPS: most MT7621 / MT7620 routers |
zray-mips-softfloat | 大端 MIPS,少数老设备BE MIPS, a few older boxes |
zray-arm-v7 | ARMv7 路由器、树莓派 2 / 3ARMv7 routers, Raspberry Pi 2 / 3 |
zray-aarch64 | ARM64 软路由、VPS、树莓派 4ARM64 mini-PCs, VPS, Pi 4 |
zray-x86_64 | PC / x86 软路由 / VPSPC / x86 mini-PC / VPS |
在 OpenWrt 上用发行版架构串最准:. /etc/openwrt_release && echo $DISTRIB_ARCH — mipsel_24kc 就选 mipsel。
On OpenWrt ask the distro: . /etc/openwrt_release && echo $DISTRIB_ARCH — mipsel_24kc means the mipsel build.
# download + verify + fix the mode
mkdir -p /mnt/zray && cd /mnt/zray
wget -O zray https://github.com/yunnysunny/zray/releases/latest/download/zray-mipsel-softfloat
wget -O zray.sha256 https://github.com/yunnysunny/zray/releases/latest/download/zray-mipsel-softfloat.sha256
chmod 755 zray
sha256sum zray # compare with the first column of zray.sha256
./zray
zig build test # unit tests, any host
zig build --release -Dtarget=mipsel-linux-musl \
-Dcpu=mips32r2+soft_float -p zig-out/mipsel
代理服务只在 Linux 上运行。macOS / Windows 上能编译、能跑 probe,服务本身不行。
The proxy service is Linux-only. On macOS / Windows you can build and run probe, nothing else.
配置Configure
zray [config.json],默认读 ./config.json。没有命令行参数,也没有环境变量。写错的键会直接报错退出,不会悄悄回退到默认值。
zray [config.json], defaulting to ./config.json. No flags, no environment variables. A mistyped key is a startup error rather than a silent fallback to a default.
{
"listen": "127.0.0.1:1080",
"server": "example.com",
"port": 443,
"sni": "example.com",
"security": "reality",
"public_key": "<server publicKey>",
"short_id": "",
"uuid": "<uuid>",
"max_connections": 64
}
普通 VLESS + TLS 节点把 security 改成 "tls",去掉 public_key / short_id,加上 "tls_verify": "none"。模式永远不会被推断出来,混合字段会报错。
For a plain VLESS + TLS node set security to "tls", drop public_key / short_id and add "tls_verify": "none". The mode is never inferred and mixed fields are rejected.
{
"outbounds": [
{ "tag": "relay", "server": "relay.example.com",
"security": "reality", "public_key": "<pk>", "uuid": "<uuid>" },
{ "tag": "exit-a", "server": "exit.example.com",
"security": "tls", "tls_verify": "none",
"uuid": "<uuid>", "network": "ws",
"ws_path": "/vless-ws", "flow": "none",
"dialer": "relay" }
],
"rules": [
{ "domain_suffix": ["openai.com", "claude.ai"],
"outbound": ["exit-a"] }
],
"default": ["relay"]
}
命中规则的流量走 relay → exit-a,失败只在组内切换、绝不退回 default;其余流量只到 relay 为止。完整原理见链式出站教程。
Matched traffic goes relay → exit-a and fails over only inside the group, never back to default; everything else stops at the relay. See the chaining tutorial.
启动日志就是它的自检报告The startup log is the health report
zray 0.4.0 socks5 [127.0.0.1:1080] redirect [] sniff true, 1 outbounds, 0 rules, 64 slots x 93 KiB = 5937 KiB
outbound -: example.com:443 sni example.com mode reality (short_id -) vision
入口、槽位与内存总量、每个上游的模式(reality / tls, verify NONE)、是否 Vision、是否 ws、是否经 hop——都在这里,不用去翻配置。
Inbounds, slots and total memory, the mode of every outbound (reality or tls, verify NONE), whether Vision and ws are on, whether it goes through a hop — it is all there, no need to re-read the config.
部署与文档Deploy and docs
OpenWrt 上用仓库里的 procd 脚本,两分钟开机自启;透明代理需要一条 nft 规则把命中集合的流量 redirect 过来。 On OpenWrt the bundled procd script gets it running at boot in two minutes; transparent proxying needs one nft rule redirecting the matching set.
scp zray config.json root@router:/mnt/zray/
scp deploy/zray.init root@router:/etc/init.d/zray
ssh root@router '
chmod 755 /etc/init.d/zray /mnt/zray/zray
chmod 600 /mnt/zray/config.json
/etc/init.d/zray enable && /etc/init.d/zray start'
table inet gfw {
set iplist { type ipv4_addr; flags interval; }
chain prerouting { type nat hook prerouting priority 0;
ip daddr @iplist ip protocol tcp redirect to :1080 }
chain output { type nat hook output priority filter;
ip daddr @iplist ip protocol tcp redirect to :1080 }
}
服务端自己的 IP 绝不能进这个集合,否则 zray 连自己的流量会被 redirect 回来。 The server's own IP must never enter that set, or zray's own outbound gets redirected back into itself.
- 使用说明User guide (中文) 五分钟跑起来、分享链接怎么填、三种部署方式、调优、排错表、从 v0.3 升级Five-minute start, share-link mapping, three deployments, tuning, a troubleshooting table, upgrading from v0.3
- User guide (English) 上面那份的英文版The same guide in English
- README 完整的功能说明与全部配置字段(中文版)Every feature and config key, in full (中文版)
- 链式出站教程:relay 和 exit 到底在干什么Chaining tutorial (中文) 为什么要把「怎么出去」和「以谁的身份出现」拆给两台机器,封装层次、代价、如何自证生效Why "how you get out" and "who you appear as" belong to different machines: layers, costs, and how to prove it works
- 状态与已知限制Status and known limits 上游 IP 变更需重启、UDP 约 600 字节上限、没有日志级别控制、还没有 TPROXYResolved once at startup, the ~600 byte UDP ceiling, no log level control, no TPROXY yet
- 源码与 IssueSource and issues Zig 0.17.0,MIT。CI 在 x86_64 和 MIPS(qemu-user)上跑单测与启动冒烟Zig 0.17.0, MIT. CI runs unit tests and a startup smoke test on x86_64 and MIPS via qemu-user