Hysteria 2 server and client
This recipe builds a complete Hysteria 2 deployment with etemenanki-app at both ends. The server listens on UDP port 443, gives each user their own credential, relays UDP as well as TCP, hides its QUIC packets with Salamander obfuscation and answers probers with an ordinary web page. The client runs on your own machine, offers a SOCKS5 port to your browser and applications, and sends their traffic to the server. The same server also accepts the upstream Hysteria client, which the second half of the page configures.
Read it when you want a working pair to start from. The Hysteria 2 protocol page is the reference for every key, limit and timeout; this page explains the choices the recipe makes and the mistakes that break a deployment.
What you build
Section titled “What you build”flowchart LR
A["Browser or application"] -->|"SOCKS5 on 127.0.0.1:1080"| B["Client: socks-in"]
B --> C["Client: hy2-out"]
C -->|"QUIC on UDP 443, Salamander"| D["Server: hy2-in"]
U["Upstream hysteria client"] -->|"QUIC on UDP 443, Salamander"| D
D --> E["direct"]
E --> F["Internet"]
D -->|"private addresses"| G["block"]
- The server has one
hysteria2inbound on UDP port 443, afreedomoutbound calleddirect, and ablackholeoutbound calledblockthat keeps clients away from the server’s own private networks. - The client has a
socksinbound on127.0.0.1:1080and ahysteria2outbound that every connection goes through, except connections to private addresses, which go out directly. - All proxied TCP connections and UDP packets from one client share one QUIC connection to the server. The client authenticates once, when that connection is made.
Before you start
Section titled “Before you start”- A server with a public IP address and a DNS name that points to it. This page uses
proxy.example.com. - etemenanki-app installed on the server and on the client machine, as described in Install.
- A TLS certificate for
proxy.example.comwhosesubjectAltNamelists that name. A certificate from a public CA, such as Let’s Encrypt, works with every client without extra settings. For a self-signed certificate, see Using a self-signed certificate. - Nothing else on the server using UDP port 443. A web server on TCP 443 is fine, because the two ports are separate, but a web server with HTTP/3 enabled also holds UDP 443.
The configuration
Section titled “The configuration”Both files pass etemenanki-app --test. Replace the name, the two user passwords and the obfuscation key before you use them.
# 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"]Deploy it
Section titled “Deploy it”-
Put the certificate in place. The server example reads
/etc/etemenanki/tls/fullchain.pemand/etc/etemenanki/tls/privkey.pem.fullchain.pemholds the leaf certificate first, followed by the intermediates. Give the private key the ownerroot:etemenankiand mode0640, as Running etemenanki-app describes for the systemd service user. -
Generate the secrets. The recipe needs one password per user and one obfuscation key shared by the server and all clients. Any random string works; for example:
Terminal window openssl rand -base64 24 # run once per user password, and once for obfs_passwordPut the values in
usersandobfs_passwordon the server. On the client,passwordisuser:passfor the user it connects as, andobfs_passwordis the server’s key, unchanged. -
Check both files.
--testbuilds everything a start would build, including loading the certificate, but binds no port:Terminal window etemenanki-app --test -c /etc/etemenanki/config.tomlConfiguration OK.It catches a missing certificate file, a key that does not belong to the certificate, a mistyped key and the other configuration errors listed under Troubleshooting. It checks each file on its own, so it cannot catch a firewall that drops the packets, or a credential or obfuscation key that differs between the server and the client.
-
Open UDP port 443. See Open the UDP port below.
-
Start the server. Under the systemd unit from Running etemenanki-app, run
systemctl start etemenanki. When the listener is up, the log shows:INFO etemenanki_app::instance: inbound hy2-in listening on udp 0.0.0.0:443ss -ulpn 'sport = :443'on the server should now list the process. -
Start the client and send a request through it.
Terminal window etemenanki-app -c config.tomlcurl -x socks5h://127.0.0.1:1080 https://example.com/The client connects to the server only when the first flow arrives, so a wrong credential, key or certificate shows up in the client’s log at the first request, not at startup.
Open the UDP port
Section titled “Open the UDP port”Hysteria 2 runs over QUIC, which is UDP. A firewall rule or cloud security group that allows TCP 443 does not let a single Hysteria 2 packet through. Allow UDP 443 inbound on the host firewall and in any provider firewall in front of the server:
ufw allow 443/udpfirewall-cmd --permanent --add-port=443/udpfirewall-cmd --reload# Adjust the table and chain names to your ruleset.nft add rule inet filter input udp dport 443 acceptWhen the port is blocked, the client sees no answer at all. Its log shows a timeout, not an authentication error:
WARN etemenanki_protocols::hysteria::slot: hysteria2: connect failed: hysteria2: no address answered (203.0.113.10:443: timed out)Binding port 443 needs root or the CAP_NET_BIND_SERVICE capability, which the systemd unit on Running etemenanki-app grants. Without it, the server fails to start with failed to start: inbound hy2-in bind udp 0.0.0.0:443 failed: Permission denied (os error 13).
The server settings
Section titled “The server settings”The server’s inbound takes the common tag, listen and port keys from Inbounds, and its Hysteria 2 keys in [inbound.settings]. This table lists the keys the recipe sets; the protocol page lists all of them.
| Key | Type | Default | Recipe value | Why |
|---|---|---|---|---|
listen |
string | "127.0.0.1" |
"0.0.0.0" |
The default is reachable only from the server itself. |
port |
u16 | none, required | 443 |
A UDP port. It does not collide with a TCP inbound on 443. |
cert_file, key_file |
path | none, required | /etc/etemenanki/tls/… |
QUIC always uses TLS 1.3; there is no plaintext mode. |
users |
array of tables | none | two entries | One credential per user, sent as user:pass. The alternative is a single shared password. |
udp |
bool | false |
true |
Without it, the server tells clients it does not relay UDP. |
udp_idle_timeout |
u64 | 60 |
60 |
Seconds of silence, in both directions, after which a UDP session is closed. Accepted range 2 to 600, and only with udp = true. |
obfs |
string (enum) | none | "salamander" |
The only accepted value, exact and lower case. |
obfs_password |
string | none | a random key | At least 4 bytes, identical on every client. |
masquerade |
table | Go’s plain 404 page not found |
an HTML 404 page | The response to every request that is not a valid authentication. |
The [inbound.settings] and [inbound.settings.masquerade] tables reject unknown keys, so a misspelling stops the config instead of starting a server without the feature you asked for.
The masquerade page
Section titled “The masquerade page”Without a credential, a Hysteria 2 server looks like an HTTP/3 web server. Every request that is not a valid authentication gets the same fixed response: a browser asking for /, a prober, and a client with the wrong credential all see the masquerade page. The recipe serves a small HTML page with status 404:
[inbound.settings.masquerade]status = 404body = "<html><body><h1>404 Not Found</h1></body></html>\n"content_type = "text/html; charset=utf-8"Any key you leave out keeps its default. status accepts 100 to 999 except 233, which is the status Hysteria 2 uses for a successful authentication; status = 233 fails with inbound hy2-in: hysteria2: 233 is the authentication success status and cannot be used for the masquerade. The masquerade is a fixed response: it cannot serve files or proxy another site.
With Salamander on, a client without the right obfuscation key cannot complete a QUIC handshake, so it never gets as far as the masquerade page.
The client settings
Section titled “The client settings”The client’s outbound uses the common server and port keys, described on Outbounds, and its Hysteria 2 keys in [outbound.settings]:
| Key | Type | Default | Recipe value | Why |
|---|---|---|---|---|
server |
string | none, required | "proxy.example.com" |
The server’s name or IP address. Without server_name, it is also the name the certificate must match. |
port |
u16 | none, required | 443 |
The server’s UDP port. A single port: there is no port range. |
password |
string | none, required | "alice:…" |
The credential. user:pass for a server with users, the bare password for a server with password. |
obfs, obfs_password |
string | none | same as the server | They must match the server exactly. |
server_name |
string | the value of server |
not set | Set it when server is an IP address and the certificate names a domain. |
ca_file |
path | none | not set | Extra trusted CA certificates, for a self-signed or private-CA certificate. |
The outbound has no [outbound.stream] block and no TLS table: server_name, ca_file and allow_insecure live in [outbound.settings]. The SOCKS inbound has UDP ASSOCIATE on by default, so SOCKS5 clients can send UDP through the tunnel as well; the server relays it because it has udp = true.
If the client should connect by IP address, keep the name for the certificate check:
[[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"How clients authenticate
Section titled “How clients authenticate”A Hysteria 2 client sends a single string, called auth in the upstream client and password in etemenanki-app, once per QUIC connection. How the server reads that string depends on which key its inbound has:
Server [inbound.settings] has |
etemenanki-app client password |
Upstream client auth |
Server’s rule |
|---|---|---|---|
users = [{ user = "alice", pass = "s3cret" }] |
"alice:s3cret" |
alice:s3cret |
Split at the first :. The name is compared ignoring ASCII case, the password exactly. |
password = "s3cret" |
"s3cret" |
s3cret |
The whole string must equal password. |
Consequences worth knowing:
Alice:s3cretalso works, because user names are lower-cased (ASCII only) before the comparison. For the same reason, two users whose names differ only in case are refused:hysteria2: two users share a name once lower-cased.- A password may contain
:, a user name may not.alice:pa:ssauthenticates asalicewith passwordpa:ss. Ausercontaining:fails withhysteria2: a username cannot contain ':' — it separates the two on the wire. - The two forms do not mix. A server with
usersrejects a bare password, and a server withpasswordcompares the wholealice:s3cretstring against its password. Setting both keys fails withpassword and users cannot both be set; a credential would have two answers. emailis accepted but has no visible effect here. Ausersentry may carry anemail, which replaces the user name as the identity attached to that user’s flows. etemenanki-app does not log that identity, count traffic by it or route on it.- Empty values are refused. A
usersentry with an emptyuserorpassfails withhysteria2: a user needs both a name and a password.
A wrong credential gets the masquerade response, not a distinct error. The etemenanki-app client logs the status it received:
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)The status in that line is the masquerade status, so a server with a custom masquerade shows its own code there.
Obfuscation must match
Section titled “Obfuscation must match”Salamander scrambles every UDP packet with a key derived from obfs_password, so that the packets no longer look like QUIC. It is not encryption, which QUIC’s own TLS already provides. Both ends apply it to every packet, including the first handshake packet, so the two ends must agree exactly:
| Server | Client | Result |
|---|---|---|
salamander, key K |
salamander, key K |
Works. |
salamander, key K |
salamander, another key |
No answer: the client times out. |
salamander |
no obfs |
No answer: the client times out. |
no obfs |
salamander |
No answer: the client times out. |
A mismatch looks exactly like a blocked port. If the client times out and the firewall is open, compare obfs_password on both sides first.
The checks at startup fail closed on both ends, so a typo never turns obfuscation off silently. Each message starts with inbound hy2-in: on the server or outbound hy2-out: on the client:
| Setting | Error |
|---|---|
obfs_password shorter than 4 bytes, or missing |
obfs_password must be at least 4 bytes for salamander |
obfs_password without obfs |
obfs_password is set but obfs is not; did you mean obfs = "salamander"? |
obfs = "Salamander" or any other value |
unknown obfs "Salamander" (expected "salamander") |
An upstream-style [outbound.settings.obfs] table |
invalid settings: invalid type: map, expected a string |
Using the upstream Hysteria client
Section titled “Using the upstream Hysteria client”The server works with the upstream Hysteria client and with other clients that follow the Hysteria 2 protocol specification. This upstream client config matches the server example, as user 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:8080Clients that import a share link take the same values in the hysteria2:// URI form, with the credential as the user-info part:
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.comPercent-encode any character in the password or key that has a meaning in a URL, such as @, /, ?, #, & or +.
When you configure an upstream client against this server:
- Leave out
bandwidth. etemenanki-app has no bandwidth settings. In its authentication reply, the server announces its receive rate asauto. The upstream client then ignores its ownbandwidthvalues and uses its configured congestion control. The fixed-rate Brutal mode is never active. - Use a single port. etemenanki-app binds exactly one UDP port, the inbound’s
port, and has no port-hopping option. Give the client that port, not a range such asproxy.example.com:20000-30000. - Use
salamanderas the obfuscation type. It is the only one the server implements. - Use
auth: user:passfor a server withusers, and the bare password for a server withpassword, as in the table under How clients authenticate. - For a self-signed certificate, set
tls.cato a copy of the server’s certificate, ortls.insecure: truefor a test.
The upstream client connects at startup unless it runs with lazy: true. With a wrong credential, that first connection fails and the client does not open its SOCKS port.
Using a self-signed certificate
Section titled “Using a self-signed certificate”Clients verify the server’s certificate against the system trust store. For a certificate you create yourself, create it without the CA flag and with a subjectAltName, then give each client a copy of it:
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:FALSEOn the etemenanki-app client, add the copy as a trusted 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 adds to the system trust store rather than replacing it, so the client still needs the system CA bundle, for example the ca-certificates package on Debian and Ubuntu. allow_insecure = true skips verification entirely; it cannot be combined with ca_file and is meant for testing only.
Renewing the certificate
Section titled “Renewing the certificate”etemenanki-app reads the certificate only when it builds a generation. Replacing the files on disk does not reload them, so after each renewal, change the config file to trigger a reload, as described in Changing a certificate or geodata file.
A reload rebuilds the whole server and closes every QUIC connection. The etemenanki-app client logs hysteria2: connection closed, reconnecting and reconnects with the next flow; upstream clients reconnect on their own. If the new certificate is broken, the reload is refused and the server keeps running with the old one.
Troubleshooting
Section titled “Troubleshooting”At startup or with --test. The message follows configuration invalid: under --test, and failed to start: when the program starts:
| Error | Cause and fix |
|---|---|
inbound hy2-in: hysteria2 needs both cert_file and key_file |
Add both keys. |
No such file or directory (os error 2) |
A cert_file, key_file or client ca_file path is wrong. The message does not name the file. Relative paths are resolved against the working directory, not the config file’s directory. |
hysteria2: certificate and key do not match: … |
key_file belongs to another certificate. |
inbound hy2-in: hysteria2 needs password or users |
Add a users table or a password. |
invalid settings: unknown field … |
A misspelled key, or a key etemenanki-app does not have, such as up, down or hop_interval. The message lists the accepted keys. |
invalid type: string "20000-30000", expected u16 |
A port range in port. Use a single port. |
failed to start: inbound hy2-in bind udp 0.0.0.0:443 failed: Address already in use (os error 98) |
Another process, or another inbound in the same file, holds UDP 443. |
failed to start: … Permission denied (os error 13) |
Binding port 443 needs root or CAP_NET_BIND_SERVICE. |
On the client, at the first request. Each line starts with hysteria2: connect failed: hysteria2: no address answered ( followed by the server address and the reason. When the name resolves to several addresses, the client tries each one and lists every reason, separated by ;:
| Reason | Cause and fix |
|---|---|
timed out |
No answer within 10 seconds for that address: UDP 443 is blocked, the server is down, or the obfs settings differ between the two ends. |
hysteria2 authentication rejected with status 404 Not Found |
Wrong credential. Use user:pass against a server with users. |
the cryptographic handshake failed: error 48: invalid peer certificate: UnknownIssuer |
The certificate is not from a trusted CA. Use a public CA certificate, or set ca_file. |
the cryptographic handshake failed: error 42: invalid peer certificate: certificate not valid for name "…" |
The name in server_name, or in server when server_name is unset, is not in the certificate’s subjectAltName. |
the cryptographic handshake failed: error 46: invalid peer certificate: Other(OtherError(CaUsedAsEndEntity)) |
The server’s certificate is marked as a CA. Recreate it with basicConstraints=critical,CA:FALSE, as in Using a self-signed certificate. |
hysteria2: no system root certificates could be loaded |
The client machine has no system CA bundle. Install one, for example the ca-certificates package. ca_file does not replace it. |
After a failed attempt, the client waits before it tries again, starting at 2 seconds and doubling up to 30. Flows that arrive in the meantime fail at once with the error hysteria2: connection is down, waiting before the next attempt instead of starting a new attempt. The hysteria2: connect failed warning appears only once per attempt, not once per flow.
If TCP works but UDP does not, the server probably has udp = false. The client logs this once:
WARN etemenanki_protocols::hysteria::connector: hysteria2: hysteria2: the server does not relay UDP; datagrams routed to this outbound are dropped