zray

单线程 · 零依赖 · 静态二进制 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.

01

它解决的问题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.

1.5
MB/s · software AES-GCM

慢核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.

93
KiB / slot

小内存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.

0
FPU · 依赖FPU · deps

无浮点、无依赖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.

02

做了什么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.

剖面方向:由外向内 Cross-section, outside in 右侧为该层的源码source file for each ring
TCP socket

非阻塞 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.

linux/conn.zig
TLS 1.3 record

记录层与密钥调度是自己写的: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".

tls/record.zig · tls/key_schedule.zig
ClientHello

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.

tls/fingerprint.zig · tls/reality.zig · tls/signature.zig
VLESS

uuid、目标地址、flow 三个字段。目标是域名还是 IP,从这里开始走两套逻辑。 Three fields: uuid, target address, flow. From here on, a name and a literal IP take different paths.

proxy/vless.zig
XTLS Vision

填充状态机认准内层 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.

proxy/vision.zig
你的流量your traffic

客户端的首包在上游握手还没结束时就被解出了 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.

proxy/sniff.zig · proxy/mux.zig
旁路——不在数据路径上,但决定了它怎么被用起来 Off the data path — but they decide how it gets used

两个入口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.

proxy/socks5.zig · linux/sys.zig

出口可以拐弯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.

proxy/ws.zig · linux/conn.zig

占用是可算的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.

main.zig
LAN phone · laptop router · zray SOCKS5 / REDIRECT 1 thread · epoll · fixed pool server REALITY · VLESS internet HTTPS from the server IP TCP TLS 1.3 TLS looks like: example.com inner: VLESS + Vision → direct copy, outer TLS is dropped for bulk traffic LAN phone · laptop TCP router · zray SOCKS5 / REDIRECT 1 thread · epoll · fixed pool TLS 1.3 looks like example.com server REALITY · VLESS + Vision bulk traffic passes through TLS internet HTTPS from the server IP one TLS layer end to end; the site only sees the server.

从设备到网站只经过一层 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.

03

实测数字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.

04

装上它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-v7ARMv7 路由器、树莓派 2 / 3ARMv7 routers, Raspberry Pi 2 / 3
zray-aarch64ARM64 软路由、VPS、树莓派 4ARM64 mini-PCs, VPS, Pi 4
zray-x86_64PC / 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.

路由器上,一行搞定On the router
# 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 0.17.0)Or build it (Zig 0.17.0)
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.

05

配置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.

config.json · REALITY
{
  "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, chaining, routing
{
  "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

两行看懂它在干什么Two lines tell you what it is doing
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.

06

部署与文档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.

OpenWrt · procd
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'
透明代理 · nftTransparent · nft
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.