Hysteria 2 服务端与客户端
本示例用 etemenanki-app 搭建一套两端完整的 Hysteria 2 部署。服务端监听 UDP 443 端口,为每个用户分配独立的凭据,除 TCP 外也中继 UDP,用 Salamander 混淆隐藏 QUIC 包,并对探测者返回一个普通网页。客户端运行在你自己的机器上,为浏览器和应用提供一个 SOCKS5 端口,并把它们的流量发往服务端。同一个服务端也接受上游 Hysteria 客户端,本页后半部分介绍它的配置。
如果你想从一套能用的服务端和客户端配置起步,就读这一页。每个配置项、上限和超时的参考见 Hysteria 2 协议页;本页解释示例中各项选择的理由,以及会导致部署失败的常见错误。
flowchart LR
A["浏览器或应用"] -->|"SOCKS5,127.0.0.1:1080"| B["客户端:socks-in"]
B --> C["客户端:hy2-out"]
C -->|"QUIC,UDP 443,Salamander"| D["服务端:hy2-in"]
U["上游 hysteria 客户端"] -->|"QUIC,UDP 443,Salamander"| D
D --> E["direct"]
E --> F["互联网"]
D -->|"私有地址"| G["block"]
- 服务端有一个监听 UDP 443 端口的
hysteria2入站、一个名为direct的freedom出站,以及一个名为block的blackhole出站,后者阻止客户端访问服务端自身所在的私有网络。 - 客户端有一个监听
127.0.0.1:1080的socks入站和一个hysteria2出站。除了发往私有地址的连接直接出站外,所有连接都经过这个hysteria2出站。 - 同一个客户端所有被代理的 TCP 连接和 UDP 包共用到服务端的一个 QUIC 连接。客户端只在建立这个连接时认证一次。
- 一台有公网 IP 地址的服务器,以及一个指向它的 DNS 名称。本页使用
proxy.example.com。 - 服务器和客户端机器上都已按安装中的说明装好 etemenanki-app。
- 一张
proxy.example.com的 TLS 证书,其subjectAltName中列有该名称。由公共 CA(例如 Let’s Encrypt)签发的证书无需额外设置即可用于所有客户端。自签名证书见使用自签名证书。 - 服务器上没有其他程序占用 UDP 443 端口。在 TCP 443 上运行的 Web 服务器没有影响,因为这是两个独立的端口;但启用了 HTTP/3 的 Web 服务器也会占用 UDP 443。
两份文件都能通过 etemenanki-app --test。使用前请替换其中的名称、两个用户的密码和混淆密钥。
# A Hysteria 2 server on UDP port 443 with two users, UDP relay, Salamander# obfuscation and an HTML masquerade page, relaying everything directly.# Replace the certificate paths, the passwords and the obfuscation key.
[log]level = "info"
[[inbound]]tag = "hy2-in"protocol = "hysteria2"listen = "0.0.0.0" # the default, 127.0.0.1, is unreachable from outsideport = 443 # a UDP port: open 443/udp in the firewall
[inbound.settings]# The certificate must carry a subjectAltName for the name clients dial.cert_file = "/etc/etemenanki/tls/fullchain.pem"key_file = "/etc/etemenanki/tls/privkey.pem"
# Clients authenticate with the string "user:pass", for example# "alice:replace-with-a-long-random-password".users = [ { user = "alice", pass = "replace-with-a-long-random-password" }, { user = "bob", pass = "replace-with-another-long-random-password" },]
# UDP relay is off by default.udp = trueudp_idle_timeout = 60
# Every client needs the same obfs and obfs_password.obfs = "salamander"obfs_password = "replace-with-a-long-random-obfs-key"
# What a prober, a browser or a client with a wrong credential receives.[inbound.settings.masquerade]status = 404body = "<html><body><h1>404 Not Found</h1></body></html>\n"content_type = "text/html; charset=utf-8"
[[outbound]]tag = "direct"protocol = "freedom"
[[outbound]]tag = "block"protocol = "blackhole"
[route]default = "direct"
# Keep clients away from the server's own private networks.[[route.rule]]outbound = "block"cidr = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fc00::/7", "::1/128"]# A local SOCKS5 proxy that sends TCP and UDP through a Hysteria 2 server,# as user alice with Salamander obfuscation. It matches hysteria2-server.toml.# Replace the server name, the credential and the obfuscation key.
[log]level = "info"
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080
[[outbound]]tag = "hy2-out"protocol = "hysteria2"server = "proxy.example.com" # the server's name or addressport = 443 # its UDP port
[outbound.settings]# "user:pass" for a server with a users table; the bare password for a# server with a shared password.password = "alice:replace-with-a-long-random-password"# Must be identical to the server's.obfs = "salamander"obfs_password = "replace-with-a-long-random-obfs-key"# server_name defaults to server. Set it when server is an IP address:# server_name = "proxy.example.com"
[[outbound]]tag = "direct"protocol = "freedom"
[route]default = "hy2-out"
# Reach the local network without the tunnel.[[route.rule]]outbound = "direct"cidr = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fc00::/7", "::1/128"]-
放置证书。 服务端示例读取
/etc/etemenanki/tls/fullchain.pem和/etc/etemenanki/tls/privkey.pem。fullchain.pem中先放叶证书,后面跟中间证书。按照运行 etemenanki-app 中针对 systemd 服务用户的说明,把私钥的属主设为root:etemenanki,权限设为0640。 -
生成密钥。 本示例需要为每个用户生成一个密码,另外生成一个由服务端和所有客户端共用的混淆密钥。任意随机字符串都可以,例如:
终端窗口 openssl rand -base64 24 # 每个用户密码运行一次,obfs_password 再运行一次在服务端把这些值填入
users和obfs_password。在客户端,password填所连接用户的user:pass,obfs_password原样填服务端的密钥。 -
检查两份文件。
--test会构建正常启动时构建的全部内容(包括加载证书),但不绑定任何端口:终端窗口 etemenanki-app --test -c /etc/etemenanki/config.tomlConfiguration OK.它能发现证书文件缺失、私钥与证书不匹配、配置项拼写错误,以及故障排查中列出的其他配置错误。它只单独检查每份文件,因此发现不了丢弃数据包的防火墙,也发现不了服务端和客户端之间凭据或混淆密钥不一致。
-
开放 UDP 443 端口。 见下文开放 UDP 端口。
-
启动服务端。 使用运行 etemenanki-app 中的 systemd 单元时,运行
systemctl start etemenanki。监听器启动后,日志中会出现:INFO etemenanki_app::instance: inbound hy2-in listening on udp 0.0.0.0:443此时在服务端运行
ss -ulpn 'sport = :443'应能看到该进程。 -
启动客户端,并通过它发送一个请求。
终端窗口 etemenanki-app -c config.tomlcurl -x socks5h://127.0.0.1:1080 https://example.com/客户端要等第一个流到达时才连接服务端,因此凭据、密钥或证书错误会在第一次请求时出现在客户端日志中,而不是在启动时。
开放 UDP 端口
Section titled “开放 UDP 端口”Hysteria 2 运行在 QUIC 之上,而 QUIC 使用的是 UDP。放行 TCP 443 的防火墙规则或云安全组,连一个 Hysteria 2 包都放不进来。请在主机防火墙以及服务器前方的所有云服务商防火墙中放行入站 UDP 443:
ufw allow 443/udpfirewall-cmd --permanent --add-port=443/udpfirewall-cmd --reload# 按你的规则集调整 table 和 chain 的名称。nft add rule inet filter input udp dport 443 accept端口被拦截时,客户端收不到任何回应。它的日志显示的是超时,而不是认证错误:
WARN etemenanki_protocols::hysteria::slot: hysteria2: connect failed: hysteria2: no address answered (203.0.113.10:443: timed out)绑定 443 端口需要 root 权限或 CAP_NET_BIND_SERVICE capability,运行 etemenanki-app 中的 systemd 单元会授予该 capability。没有它时,服务端启动失败,报错 failed to start: inbound hy2-in bind udp 0.0.0.0:443 failed: Permission denied (os error 13)。
服务端入站使用入站中的通用配置项 tag、listen 和 port,Hysteria 2 自身的配置项放在 [inbound.settings] 中。下表列出本示例设置的配置项;完整列表见协议页。
| 配置项 | 类型 | 默认值 | 示例取值 | 原因 |
|---|---|---|---|---|
listen |
string | "127.0.0.1" |
"0.0.0.0" |
默认值只能从服务器本机访问。 |
port |
u16 | 无,必填 | 443 |
这是 UDP 端口,不会与 443 上的 TCP 入站冲突。 |
cert_file、key_file |
path | 无,必填 | /etc/etemenanki/tls/… |
QUIC 始终使用 TLS 1.3,没有明文模式。 |
users |
array of tables | 无 | 两个条目 | 每个用户一个凭据,以 user:pass 形式发送。另一种方式是使用单个共享的 password。 |
udp |
bool | false |
true |
不开启时,服务端会告诉客户端它不中继 UDP。 |
udp_idle_timeout |
u64 | 60 |
60 |
UDP 会话在两个方向都静默达到这么多秒后关闭。可接受范围为 2 到 600,且仅在 udp = true 时可用。 |
obfs |
string (enum) | 无 | "salamander" |
唯一可接受的值,必须完全一致且为小写。 |
obfs_password |
string | 无 | 一个随机密钥 | 至少 4 字节,所有客户端上都必须相同。 |
masquerade |
table | Go 的纯文本 404 page not found |
一个 HTML 404 页面 | 对所有不是有效认证的请求返回的响应。 |
[inbound.settings] 和 [inbound.settings.masquerade] 两张表都拒绝未知配置项,因此拼写错误会使配置无法加载,而不是启动一个缺少你想要的功能的服务端。
在没有凭据的人看来,Hysteria 2 服务端就像一个 HTTP/3 Web 服务器。所有不是有效认证的请求都得到同一个固定响应:请求 / 的浏览器、探测者和凭据错误的客户端,看到的都是伪装页。本示例返回一个状态码为 404 的简单 HTML 页面:
[inbound.settings.masquerade]status = 404body = "<html><body><h1>404 Not Found</h1></body></html>\n"content_type = "text/html; charset=utf-8"省略的配置项保留默认值。status 接受 100 到 999,但不能是 233,因为这是 Hysteria 2 表示认证成功的状态码;设置 status = 233 会报错 inbound hy2-in: hysteria2: 233 is the authentication success status and cannot be used for the masquerade。伪装页是一个固定响应:它不能提供文件,也不能反向代理其他网站。
开启 Salamander 后,没有正确混淆密钥的客户端无法完成 QUIC 握手,因此根本到不了伪装页这一步。
客户端出站使用出站中介绍的通用配置项 server 和 port,Hysteria 2 自身的配置项放在 [outbound.settings] 中:
| 配置项 | 类型 | 默认值 | 示例取值 | 原因 |
|---|---|---|---|---|
server |
string | 无,必填 | "proxy.example.com" |
服务端的名称或 IP 地址。未设置 server_name 时,证书也必须与这个名称匹配。 |
port |
u16 | 无,必填 | 443 |
服务端的 UDP 端口。只能是单个端口,不支持端口范围。 |
password |
string | 无,必填 | "alice:…" |
凭据。服务端使用 users 时填 user:pass,使用 password 时填纯密码。 |
obfs、obfs_password |
string | 无 | 与服务端相同 | 必须与服务端完全一致。 |
server_name |
string | server 的值 |
未设置 | 当 server 是 IP 地址而证书中写的是域名时设置它。 |
ca_file |
path | 无 | 未设置 | 额外信任的 CA 证书,用于自签名证书或私有 CA 签发的证书。 |
该出站没有 [outbound.stream] 块,也没有 TLS 表:server_name、ca_file 和 allow_insecure 都放在 [outbound.settings] 中。SOCKS 入站默认开启 UDP ASSOCIATE,因此 SOCKS5 客户端也可以通过隧道发送 UDP;服务端设置了 udp = true,所以会中继这些 UDP 流量。
如果客户端要通过 IP 地址连接,请保留名称用于证书校验:
[[outbound]]tag = "hy2-out"protocol = "hysteria2"server = "203.0.113.10"port = 443
[outbound.settings]password = "alice:replace-with-a-long-random-password"server_name = "proxy.example.com"obfs = "salamander"obfs_password = "replace-with-a-long-random-obfs-key"客户端如何认证
Section titled “客户端如何认证”Hysteria 2 客户端在每个 QUIC 连接上发送一次单个字符串,这个字符串在上游客户端中叫 auth,在 etemenanki-app 中叫 password。服务端如何解读这个字符串,取决于它的入站设置了哪个配置项:
服务端 [inbound.settings] 中设置了 |
etemenanki-app 客户端的 password |
上游客户端的 auth |
服务端规则 |
|---|---|---|---|
users = [{ user = "alice", pass = "s3cret" }] |
"alice:s3cret" |
alice:s3cret |
在第一个 : 处拆分。用户名比较时忽略 ASCII 大小写,密码则精确比较。 |
password = "s3cret" |
"s3cret" |
s3cret |
整个字符串必须等于 password。 |
值得了解的后果:
Alice:s3cret同样有效,因为比较前会把用户名转为小写(仅限 ASCII)。出于同样的原因,只有大小写不同的两个用户名会被拒绝:hysteria2: two users share a name once lower-cased。- 密码可以包含
:,用户名不行。alice:pa:ss会以用户alice、密码pa:ss认证。包含:的user会报错hysteria2: a username cannot contain ':' — it separates the two on the wire。 - 两种形式不能混用。 设置了
users的服务端拒绝纯密码;设置了password的服务端会把整个alice:s3cret字符串与其密码比较。两个配置项同时设置会报错password and users cannot both be set; a credential would have two answers。 - 可以设置
email,但在这里没有可见效果。users条目可以带一个email,它会代替用户名,作为附加在该用户的流上的身份标识。etemenanki-app 不会记录这个身份,也不按它统计流量或路由。 - 空值会被拒绝。
user或pass为空的users条目会报错hysteria2: a user needs both a name and a password。
凭据错误时得到的是伪装响应,而不是单独的错误。etemenanki-app 客户端会记录收到的状态码:
WARN etemenanki_protocols::hysteria::slot: hysteria2: connect failed: hysteria2: no address answered (203.0.113.10:443: hysteria2 authentication rejected with status 404 Not Found)这一行中的状态码就是伪装页的 status,因此使用自定义伪装页的服务端会在这里显示它自己的状态码。
混淆必须一致
Section titled “混淆必须一致”Salamander 用从 obfs_password 派生出的密钥打乱每个 UDP 包,使这些包看起来不再像 QUIC。它不是加密,加密已由 QUIC 自身的 TLS 提供。两端都会对每个包(包括第一个握手包)进行混淆,因此两端必须完全一致:
| 服务端 | 客户端 | 结果 |
|---|---|---|
salamander,密钥 K |
salamander,密钥 K |
正常工作。 |
salamander,密钥 K |
salamander,其他密钥 |
没有回应:客户端超时。 |
salamander |
未设置 obfs |
没有回应:客户端超时。 |
未设置 obfs |
salamander |
没有回应:客户端超时。 |
混淆不一致的表现与端口被拦截完全相同。如果客户端超时而防火墙已开放,先比对两端的 obfs_password。
两端的启动检查都是 fail closed(出错即拒绝)的,因此拼写错误绝不会悄悄关闭混淆。服务端的报错以 inbound hy2-in: 开头,客户端的报错以 outbound hy2-out: 开头:
| 设置 | 报错 |
|---|---|
obfs_password 短于 4 字节或缺失 |
obfs_password must be at least 4 bytes for salamander |
设置了 obfs_password 但没有 obfs |
obfs_password is set but obfs is not; did you mean obfs = "salamander"? |
obfs = "Salamander" 或其他任何值 |
unknown obfs "Salamander" (expected "salamander") |
上游风格的 [outbound.settings.obfs] 表 |
invalid settings: invalid type: map, expected a string |
使用上游 Hysteria 客户端
Section titled “使用上游 Hysteria 客户端”服务端可以与上游 Hysteria 客户端,以及其他遵循 Hysteria 2 协议规范的客户端配合使用。下面这份上游客户端配置以用户 alice 的身份与服务端示例对应:
server: proxy.example.com:443auth: alice:replace-with-a-long-random-passwordobfs: type: salamander salamander: password: replace-with-a-long-random-obfs-keytls: sni: proxy.example.comsocks5: listen: 127.0.0.1:1080http: listen: 127.0.0.1:8080支持导入分享链接的客户端使用 hysteria2:// URI 形式表示相同的值,凭据放在 user-info 部分:
hysteria2://alice:replace-with-a-long-random-password@proxy.example.com:443/?obfs=salamander&obfs-password=replace-with-a-long-random-obfs-key&sni=proxy.example.com密码或密钥中凡是在 URL 里有特殊含义的字符,例如 @、/、?、#、& 或 +,都要进行百分号编码。
为这个服务端配置上游客户端时:
- 不要设置
bandwidth。 etemenanki-app 没有带宽设置。服务端在认证应答中把自己的接收速率声明为auto,上游客户端随后会忽略自身的bandwidth值,改用其配置的拥塞控制算法。固定速率的 Brutal 模式永远不会启用。 - 使用单个端口。 etemenanki-app 只绑定一个 UDP 端口,即入站的
port,也没有端口跳跃选项。请把这个端口告诉客户端,而不是proxy.example.com:20000-30000这样的范围。 - 混淆类型使用
salamander。 这是服务端实现的唯一一种混淆。 - 使用
auth: user:pass:服务端设置users时如此,设置password时则填纯密码,见客户端如何认证中的表格。 - 使用自签名证书时,把
tls.ca设为服务端证书的一份副本;测试时也可以设置tls.insecure: true。
除非以 lazy: true 运行,上游客户端会在启动时就连接服务端。凭据错误时,这第一次连接会失败,客户端也不会打开它的 SOCKS 端口。
使用自签名证书
Section titled “使用自签名证书”客户端会依据系统信任库校验服务端证书。如果证书是你自己创建的,创建时不要带 CA 标志,并加上 subjectAltName,然后给每个客户端一份副本:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ -keyout privkey.pem -out fullchain.pem -days 365 -subj /CN=proxy.example.com \ -addext subjectAltName=DNS:proxy.example.com \ -addext basicConstraints=critical,CA:FALSE在 etemenanki-app 客户端上,把这份副本添加为受信任的 CA:
[outbound.settings]password = "alice:replace-with-a-long-random-password"ca_file = "/etc/etemenanki/proxy-cert.pem"obfs = "salamander"obfs_password = "replace-with-a-long-random-obfs-key"ca_file 是在系统信任库之外追加,而不是替换它,因此客户端仍然需要系统 CA 证书包,例如 Debian 和 Ubuntu 上的 ca-certificates 软件包。allow_insecure = true 会完全跳过校验;它不能与 ca_file 同时使用,仅供测试。
etemenanki-app 只在构建 generation(一代实例)时读取证书。替换磁盘上的文件并不会让它们重新加载,所以每次续期后都要修改配置文件以触发重载,具体见热重载中关于更换证书或 geodata 文件的说明。
重载会重建整个服务端,并关闭所有 QUIC 连接。etemenanki-app 客户端会记录 hysteria2: connection closed, reconnecting,并在下一个流到来时重新连接;上游客户端会自行重连。如果新证书有问题,重载会被拒绝,服务端继续使用旧证书运行。
在启动时或使用 --test 时出现的报错。使用 --test 时,报错跟在 configuration invalid: 之后;正式启动时,跟在 failed to start: 之后:
| 报错 | 原因和解决方法 |
|---|---|
inbound hy2-in: hysteria2 needs both cert_file and key_file |
两个配置项都要设置。 |
No such file or directory (os error 2) |
cert_file、key_file 或客户端 ca_file 的路径有误。报错中不会指明是哪个文件。相对路径以工作目录为基准解析,而不是配置文件所在的目录。 |
hysteria2: certificate and key do not match: … |
key_file 属于另一张证书。 |
inbound hy2-in: hysteria2 needs password or users |
添加 users 表或 password。 |
invalid settings: unknown field … |
配置项拼写错误,或使用了 etemenanki-app 不支持的配置项,例如 up、down 或 hop_interval。报错中会列出可接受的配置项。 |
invalid type: string "20000-30000", expected u16 |
在 port 中写了端口范围。请使用单个端口。 |
failed to start: inbound hy2-in bind udp 0.0.0.0:443 failed: Address already in use (os error 98) |
另一个进程,或同一文件中的另一个入站,占用了 UDP 443。 |
failed to start: … Permission denied (os error 13) |
绑定 443 端口需要 root 权限或 CAP_NET_BIND_SERVICE。 |
在客户端上,第一次请求时出现的报错。每一行都以 hysteria2: connect failed: hysteria2: no address answered ( 开头,后面是服务端地址和原因。当名称解析出多个地址时,客户端会逐个尝试,并列出每个地址的原因,以 ; 分隔:
| 原因 | 原因和解决方法 |
|---|---|
timed out |
该地址在 10 秒内没有回应:UDP 443 被拦截、服务端未运行,或两端的 obfs 设置不一致。 |
hysteria2 authentication rejected with status 404 Not Found |
凭据错误。服务端设置了 users 时,请使用 user:pass。 |
the cryptographic handshake failed: error 48: invalid peer certificate: UnknownIssuer |
证书不是由受信任的 CA 签发的。请使用公共 CA 签发的证书,或设置 ca_file。 |
the cryptographic handshake failed: error 42: invalid peer certificate: certificate not valid for name "…" |
server_name 中的名称(未设置 server_name 时为 server 中的名称)不在证书的 subjectAltName 中。 |
the cryptographic handshake failed: error 46: invalid peer certificate: Other(OtherError(CaUsedAsEndEntity)) |
服务端证书被标记为 CA。请按使用自签名证书中的做法,用 basicConstraints=critical,CA:FALSE 重新创建证书。 |
hysteria2: no system root certificates could be loaded |
客户端机器上没有系统 CA 证书包。请安装一个,例如 ca-certificates 软件包。ca_file 不能代替它。 |
一次尝试失败后,客户端会等待一段时间再重试,等待时间从 2 秒开始,每次翻倍,最长 30 秒。在此期间到来的流会立即失败,报错 hysteria2: connection is down, waiting before the next attempt,而不会发起新的尝试。hysteria2: connect failed 警告每次尝试只出现一次,而不是每个流一次。
如果 TCP 正常而 UDP 不通,很可能是服务端设置了 udp = false。客户端会记录一次:
WARN etemenanki_protocols::hysteria::connector: hysteria2: hysteria2: the server does not relay UDP; datagrams routed to this outbound are dropped